# Suggestions

> **Info:** This is release candidate documentation for **Stream Chat React SDK v15**. For the latest stable version, see the [latest version (v14)](https://getstream.io/chat/docs/sdk/react/).

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.

>
> **Info:** **Example**
>
> The user types "Thanks @jo" in a channel:
>
> 1. `TextComposer.handleChange()` runs the text chain. The mentions middleware finds `@jo` just before the cursor.
> 2. It sets `textComposer.state.suggestions` to `{ trigger: "@", query: "jo", searchSource }`.
> 3. When the chain is done, the composer calls `searchSource.search("jo")`. The search source keeps the results in its own `state`: `items`, `isLoading` and `hasNext`.
> 4. The user picks John. `TextComposer.handleSelect(item)` runs the chain again: the text becomes "Thanks @John ", John is added to `textComposer.mentions`, and `suggestions` is 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.

```ts
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.
- **`@channel` and `@here`** appear on the first page, when they start with what was typed.
- **Roles** are searched with `client.searchRoles()`, and **user groups** with `client.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](https://getstream.io/chat/docs/sdk/react/v15/state/writing-messages/composer-middleware/#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:

```ts
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 with `client.queryUsers()`, not only the channel's members.
- `userFilters` and `userSort`, `memberFilters` and `memberSort`: the filters and sort for the user and member queries. `userFilters` is added to the default search by id and name. The other three replace their defaults, and the member query uses only the first entry of `memberSort`.
- `searchOptions`: extra options for those queries.
- `pageSize`, `shortQueryDebounceMs`, `longQueryDebounceMs` and `shortQueryMaxLength`: page size and debouncing. Queries of up to `shortQueryMaxLength` characters (2) wait `shortQueryDebounceMs` (500 ms), and longer ones wait `longQueryDebounceMs` (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, `channel` or `here`.

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:

```ts
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)` and `prepareQueryMembersParams(searchQuery, offset)` return the `filters`, `sort` and `options` of 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.

```ts
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:

```ts
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](https://getstream.io/chat/docs/sdk/react/v15/features/rich-content/emoji/) shows the emoji middleware, which works the same way with `:` as its trigger, and [Custom suggestions](https://getstream.io/chat/docs/sdk/react/v15/recipes/composer/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 `/giphy` and typing `cats` sends `cats`. A command typed in full stays in the text.

## Used in

- [Mentions](https://getstream.io/chat/docs/sdk/react/v15/features/messaging/mentions/)
- [Commands and Giphy](https://getstream.io/chat/docs/sdk/react/v15/features/rich-content/commands-and-giphy/)
- [Emoji](https://getstream.io/chat/docs/sdk/react/v15/features/rich-content/emoji/)
- [Custom suggestions](https://getstream.io/chat/docs/sdk/react/v15/recipes/composer/custom-suggestions/)

---

For the most recent version of this documentation, visit [https://getstream.io/chat/docs/sdk/react/v15/state/writing-messages/suggestions/](https://getstream.io/chat/docs/sdk/react/v15/state/writing-messages/suggestions/).