Info:
This is release candidate documentation for Stream Chat React Native SDK v10. For the latest stable version, see the latest version (v9).

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.

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.

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.

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.

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 @Cleo 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. An @ with nothing typed after it always lists the loaded members and watchers, whatever the channel's size.
  • @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() empties the composer too, but TextComposer.clearCommand() then restores its text as well.

When the message is sent, the command has to go back in front of the text. Four middleware do that, and the SDK's setupCommandUIMiddlewares() installs them. See Command middleware.

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 { defaultCommandSendabilityValidator, StreamChat } from "stream-chat";
import type { CommandSendValidator } from "stream-chat";

const client = StreamChat.getInstance("YOUR_API_KEY");

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 } } });

When a list opens

createMentionsMiddleware(channel, options) and createCommandsMiddleware(channel, options) take two options that decide when their list opens:

  • minChars is the number of characters, the trigger included, the user types before the list opens. It's 1 by default, so the trigger alone opens the list. With minChars: 3, the mentions list opens at @jo, and not at @ or @j.
  • trigger is 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's AutoCompleteSuggestionList renders 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.

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. The members and watchers are then never searched locally.
  • 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.
  • textComposerText: the composer's text. The mentions middleware sets it before each search. When it contains /unmute, only muted users are offered. A search source you call yourself, outside the middleware, needs it too: without it, the search finds nothing.
  • trigger: the mentions middleware's trigger, which the search source uses when it matches names locally. Pass the same one to both.

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 {
  createMentionsMiddleware,
  MentionsSearchSource,
  StreamChat,
} from "stream-chat";
import type { TextComposerMiddleware } from "stream-chat";

const client = StreamChat.getInstance("YOUR_API_KEY");

const removeAccents = (text: string) =>
  text.normalize("NFD").replace(/[\u0300-\u036f]/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.
import { MentionsSearchSource } from "stream-chat";
import type { MentionSuggestion } 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 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.
  • Picked commands are sent without their name. Without the command middleware, picking /giphy and typing cats sends cats. A command typed in full stays in the text.

Used in