Autocomplete Suggestions

One of the advanced features of the message input is autocompletion support. By default, it autocompletes mentions, commands and emojis.

Autocomplete suggestions are triggered by typing the special characters:

TriggerActionExample
@mention@user
/command/giphy
:emoji:smiling

The default message input component provided by the SDK supports this out of the box. When a trigger character is typed into the message input, a list of suggested options appears:

If you want to customize the look and behavior of the suggestions list, you have two options:

  1. Use the default message input provided by the SDK, and override the following components to customize the look and feel of the suggestion list:

  2. Implement the message input component from scratch, and add autocomplete functionality to it yourself.

Let’s explore both options.

Overriding the Suggestion Item Component

Let’s start by creating a custom suggestion item component.

As usual, to override a component used by the SDK, you should pass a custom component as a prop to the Channel component in your application code. In this case we are overriding AutocompleteSuggestionItem:

import {
  Chat,
  Channel,
  ChannelHeader,
  ChannelList,
  MessageList,
  Thread,
  Window,
  MessageInput,
} from 'stream-chat-react';
import { SearchIndex } from 'emoji-mart';

export const App = () => (
  <Chat client={chatClient}>
    <ChannelList filters={filters} sort={sort} options={options} />
    <Channel AutocompleteSuggestionItem={CustomSuggestionItem} emojiSearchIndex={SearchIndex}>
      <Window>
        <ChannelHeader />
        <MessageList />
        <MessageInput />
      </Window>
      <Thread />
    </Channel>
  </Chat>
);

Since we are not overriding the entire suggestion list yet, just an item component, our custom item component will get all the necessary data and callbacks in props - the default AutocompleteSuggestionList will take care of that.

This makes the basic implementation pretty straightforward. Two things to note, though:

  1. To show different previews for different item types (e.g. we want to show avatars for users and emoji previews for emojis) we need to put in type guards for each item type.
  2. The default AutocompleteSuggestionList requires you to call the onSelectHandler callback when an item is focused or hovered. This is to ensure that items in the list are keyboard accessible.
const CustomSuggestionItem = (props) => {
  const { item } = props;
  let children = null;

  // Item is an emoji suggestion
  if ('native' in item && typeof item.native === 'string') {
    children = (
      <>
        <span>{item.native}</span>
        {item.name}
      </>
    );
  }

  // Item is a user to be mentioned
  if (!('native' in item) && 'id' in item) {
    children = (
      <>
        <Avatar image={item.image} />
        {item.name ?? item.id}
      </>
    );
  }

  // Item is a command configured for the current channel
  if ('name' in item && 'description' in item) {
    children = (
      <>
        <strong>/{item.name}</strong>
        {item.description}
      </>
    );
  }

  return (
    <button
      type='button'
      className={`suggestion-list__item ${props.selected ? 'suggestion-list__item_selected' : ''}`}
      onFocus={() => props.onSelectHandler(item)}
      onMouseEnter={() => props.onSelectHandler(item)}
      onClick={(event) => props.onClickHandler(event, item)}
    >
      {children}
    </button>
  );
};

If you are building an internationalized application, you will need to translate command descriptions and arguments. Translations for all of the supported languages are provided with the SDK. You can access them by using the translation helper function provided in the TranslationContext. All you need to do is to query the translation with the right key: <command-name>-command-description for the description, and <command-name>-command-args for the arguments (you can always refer to our translation files to check if the key is correct).

// In CustomSuggestionItem component:
const { t } = useTranslationContext();

// Item is a command configured for the current channel
if ('name' in item && 'description' in item) {
  children = (
    <>
      <strong>/{item.name}</strong>
      {t(`${item.name}-command-description`, {
        defaultValue: item.description,
      })}
    </>
  );
}

The English (US) translation is loaded from the Stream backend as part of the channel config object, and is not part of the translation resources, so we explicitly set it as the fallback value.

Overriding the Suggestion List Component

If you want to further customize the default behavior of the suggestion list, you can override the entire list component.

This is an easy way to add a header or footer to the list. You don’t have to reimplement the whole list component, just create a small wrapper:

import { DefaultSuggestionList } from 'stream-chat-react';

const SuggestionListWithHeader = (props) => {
  let header = '';

  if (props.currentTrigger === '@') {
    header = 'Users';
  }

  if (props.currentTrigger === '/') {
    header = 'Commands';
  }

  if (props.currentTrigger === ':') {
    header = 'Emoji';
  }

  if (props.value && props.value.length > 1) {
    header += ` matching "${props.value.slice(1)}"`;
  }

  return (
    <>
      <div className='suggestion-list__header'>{header}</div>
      <DefaultSuggestionList {...props} />
    </>
  );
};

This wrapper uses two props (provided to your component by the default message input component) to construct a header: currentTrigger contains the special character that triggered autocomplete, and value contains the query the user has typed so far.

Then override the AutocompleteSuggestionList list at the Channel level, and you’re done:

import {
  Chat,
  Channel,
  ChannelHeader,
  ChannelList,
  MessageList,
  Thread,
  Window,
  MessageInput,
} from 'stream-chat-react';
import { SearchIndex } from 'emoji-mart';

export const App = () => (
  <Chat client={chatClient}>
    <ChannelList filters={filters} sort={sort} options={options} />
    <Channel AutocompleteSuggestionList={SuggestionListWithHeader} emojiSearchIndex={SearchIndex}>
      <Window>
        <ChannelHeader />
        <MessageList />
        <MessageInput />
      </Window>
      <Thread />
    </Channel>
  </Chat>
);

Or you can go deeper and reimplement the entire list component from scratch. Note that in this case it’s up to you to handle the interactions and accessibility. This example implementation is a good starting point, but it doesn’t handle any keyboard interactions:

const CustomSuggestionList = (props) => {
  const [selectedIndex, setSelectedIndex] = useState(0);

  if (selectedIndex >= props.values.length && selectedIndex !== 0) {
    setSelectedIndex(0);
  }

  const handleClick = (item) => {
    props.onSelect(props.getTextToReplace(item));
  };

  return (
    <ul className='suggestion-list'>
      {props.values.map((item, index) => (
        <li key={index} className='suggestion-list__item-container'>
          <CustomSuggestionItem
            item={item}
            selected={index === selectedIndex}
            onSelectHandler={() => setSelectedIndex(index)}
            onClickHandler={() => handleClick(item)}
          />
        </li>
      ))}
    </ul>
  );
};

Implementing Autocompletion for Custom Message Input

Finally, if you are implementing the entire message input component from scratch, it’s up to you to build the autocomplete functionality. The good news is that the autocompleteTriggers value in the MessageInputContext provides a lot of reusable functionality. The bad news is that properly implementing autocomplete is still a lot of work.

Let’s try to tackle that. We’ll start with the simple message input implementation from the Message Input UI cookbook:

import { useMessageInputContext } from 'stream-chat-react';

const CustomMessageInput = () => {
  const { text, handleChange, handleSubmit } = useMessageInputContext();

  return (
    <div className='message-input'>
      <textarea value={text} className='message-input__input' onChange={handleChange} />
      <button type='button' className='message-input__button' onClick={handleSubmit}>
        ⬆️
      </button>
    </div>
  );
};

The autocompleteTriggers object is a map between special characters that trigger autocompletion suggestions and some useful functions:

  • dataProvider returns (via a callback) the list of suggestions, given the current query (the text after the trigger)
  • callback is the action that should be called when a suggestion is selected (e.g. when a user mention is selected, it modifies the message payload to include the mention)
  • output function returns the replacement text, given one of the items returned by the dataProvider

The keys of the autocompleteTriggers are the characters that should trigger autocomplete suggestions.

We’ll start by using the dataProvider to display the list of suggestions once the trigger character is entered by the user. We use a (memoized) regular expression to find trigger characters, and then we query the dataProvider for suggestions.

When the suggestions are ready, the dataProvider invokes a callback, where we update the current suggestion list. We must be careful not to run into a race condition here, so before the update, we check to see if the input has changed since we queried the suggestions.

Finally, we render the suggestion list:

import { useMessageInputContext } from 'stream-chat-react';

function CustomMessageInput() {
  const {
    text,
    autocompleteTriggers,
    handleChange: onChange,
    handleSubmit,
  } = useMessageInputContext();

  const inputRef = useRef < HTMLTextAreaElement > null;
  const [trigger, setTrigger] = (useState < string) | (null > null);
  const [suggestions, setSuggestions] = useState([]);

  const triggerQueryRegex = useMemo(() => {
    if (!autocompleteTriggers) {
      return null;
    }

    const triggerCharacters = Object.keys(autocompleteTriggers);
    // One of the trigger characters, followed by one or more non-space characters
    return new RegExp(String.raw`([${triggerCharacters}])(\S+)`);
  }, [autocompleteTriggers]);

  const handleChange = (event) => {
    onChange(event);

    if (autocompleteTriggers && triggerQueryRegex) {
      const input = event.currentTarget;
      const updatedText = input.value;
      const textBeforeCursor = updatedText.slice(0, input.selectionEnd);
      const match = textBeforeCursor.match(triggerQueryRegex);

      if (!match) {
        setTrigger(null);
        setSuggestions([]);
        return;
      }

      const trigger = match[1];
      const query = match[2];

      // Query the dataProvider for the suggestions
      autocompleteTriggers[trigger].dataProvider(query, updatedText, (suggestions) => {
        // Unless the input has changed, update the suggestion list
        if (input.value === updatedText) {
          setTrigger(match[0]);
          setSuggestions(suggestions);
        }
      });
    }
  };

  return (
    <div className='message-input'>
      {suggestions.length > 0 && (
        <div className='suggestions'>
          {suggestions.map((item) => (
            <button className='suggestions__item'>
              {'native' in item && typeof item.native === 'string' && item.native}
              <strong>{item.name}</strong>
              {'description' in item && typeof item.description === 'string' && item.description}
            </button>
          ))}
        </div>
      )}
      <div className='message-input__composer'>
        <textarea
          ref={inputRef}
          className='message-input__input'
          value={text}
          onChange={handleChange}
        />
        <button type='button' className='message-input__button' onClick={handleSubmit}>
          ⬆️
        </button>
      </div>
    </div>
  );
}

But we are not done yet. Displaying suggestions is only half of the puzzle. The second half is reacting when the user selects one of the suggestions.

When the user selects a suggestion, we should do two things:

  1. Call the callback for the current trigger. This will, for example, update the message payload with a user mention.
  2. Examine the return value of the output for the selected suggestion, and update the message input text accordingly.

The output returns an object that looks something like this:

{
  "text": "replacement text",
  "caretPosition": "next"
}

The text property tells us what to replace the current trigger with, and the caretPosition property tells us where the cursor should end up after the update: either before or after the inserted replacement.

Let’s add a handler for the click event on the suggestion:

const handleSuggestionClick = (item) => {
  if (autocompleteTriggers && trigger) {
    const { callback, output } = autocompleteTriggers[trigger[0]];
    callback?.(item);
    const replacement = output(item);

    if (replacement) {
      const start = text.indexOf(trigger);
      const end = start + trigger.length;
      const caretPosition =
        replacement.caretPosition === 'start' ? start : start + replacement.text.length;

      const updatedText = text.slice(0, start) + replacement.text + text.slice(end);
      flushSync(() => setText(updatedText));
      inputRef.current?.focus();
      inputRef.current?.setSelectionRange(caretPosition, caretPosition);
    }
  }
};

And there you have it, the complete example that you can use as a starting point for your own autocomplete implementation:

import { useMessageInputContext } from 'stream-chat-react';

function CustomMessageInput() {
  const {
    text,
    autocompleteTriggers,
    handleChange: onChange,
    handleSubmit,
  } = useMessageInputContext();

  const inputRef = useRef < HTMLTextAreaElement > null;
  const [trigger, setTrigger] = (useState < string) | (null > null);
  const [suggestions, setSuggestions] = useState([]);

  const triggerQueryRegex = useMemo(() => {
    if (!autocompleteTriggers) {
      return null;
    }

    const triggerCharacters = Object.keys(autocompleteTriggers);
    return new RegExp(String.raw`([${triggerCharacters}])(\S+)`);
  }, [autocompleteTriggers]);

  const handleChange = (event) => {
    onChange(event);

    if (autocompleteTriggers && triggerQueryRegex) {
      const input = event.currentTarget;
      const updatedText = input.value;
      const textBeforeCursor = updatedText.slice(0, input.selectionEnd);
      const match = textBeforeCursor.match(triggerQueryRegex);

      if (!match) {
        setTrigger(null);
        setSuggestions([]);
        return;
      }

      const trigger = match[1];
      const query = match[2];

      autocompleteTriggers[trigger].dataProvider(query, updatedText, (suggestions) => {
        if (input.value === updatedText) {
          setTrigger(match[0]);
          setSuggestions(suggestions);
        }
      });
    }
  };

  const handleSuggestionClick = (item) => {
    if (autocompleteTriggers && trigger) {
      const { callback, output } = autocompleteTriggers[trigger[0]];
      callback?.(item);
      const replacement = output(item);

      if (replacement) {
        const start = text.indexOf(trigger);
        const end = start + trigger.length;
        const caretPosition =
          replacement.caretPosition === 'start' ? start : start + replacement.text.length;

        const updatedText = text.slice(0, start) + replacement.text + text.slice(end);
        flushSync(() => setText(updatedText));
        inputRef.current?.focus();
        inputRef.current?.setSelectionRange(caretPosition, caretPosition);
      }
    }
  };

  return (
    <div className='message-input'>
      {suggestions.length > 0 && (
        <div className='suggestions'>
          {suggestions.map((item) => (
            <button className='suggestions__item' onClick={() => handleSuggestionClick(item)}>
              {'native' in item && typeof item.native === 'string' && item.native}
              <strong>{item.name}</strong>
              {'description' in item && typeof item.description === 'string' && item.description}
            </button>
          ))}
        </div>
      )}
      <div className='message-input__composer'>
        <textarea
          ref={inputRef}
          className='message-input__input'
          value={text}
          onChange={handleChange}
        />
        <button type='button' className='message-input__button' onClick={handleSubmit}>
          ⬆️
        </button>
      </div>
    </div>
  );
}
© Getstream.io, Inc. All Rights Reserved.