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 onChange handlers of the text composer's middleware chain. The mentions middleware finds @jo just before the cursor.
  2. It sets textComposer.state.suggestions to { trigger: "@", query: "jo", searchSource }.
  3. When the handlers are 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's onSuggestionItemSelect handlers: the text becomes "Thanks @John ", John is added to textComposer.mentions, and suggestions is 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.

Used in