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

Typing indicators

Typing indicators show who is writing a message right now. This page covers the typing events behind them, when they are sent, and how to change the indicator.

What users get

Bob starts typing in a channel. On the user's screen, his avatar and three animated dots appear at the bottom of the message list, and the subtitle in the channel header changes to "Bob is typing". With two people typing it reads "Bob and Cleo are typing", and with more, "3 people are typing". The indicator in the list stays for two seconds after the last person stops, so it doesn't flicker between words. Screen readers hear the same text.

Nobody sees their own typing.

How it works across the layers

  • State. Channel.keystroke() and Channel.stopTyping() send typing.start and typing.stop events. When another member's event arrives, the channel stores it in channel.state.typing, keyed by user id. The channel's composer keeps the same map in channel.messageComposer.textComposer.state.
  • UI. The text composer calls Channel.keystroke() as the user types. MessageList renders TypingIndicator at the bottom of the list. ChannelHeader and ThreadHeader render TypingIndicatorHeader in their subtitle. VirtualizedMessageList renders a typing indicator only when you register one, see Customizing.
  • Layout. Not involved.

What channels and threads hold explains the rest of channel.state. Channel UI shows where the indicators sit.

Typing events

A typing event belongs to a channel. One sent from a thread carries the thread's id as parent_id, so the same event can show in the channel or in one of its threads.

Sending typing events

Info:

Example

The user types "Hello" in the composer:

  1. Their first keystroke calls Channel.keystroke(), which sends typing.start.
  2. Their next keystrokes call it again, but it sends nothing until two seconds have passed since the last typing.start.
  3. They stop typing. A second later, the client sends typing.stop by itself.
  4. If they press Enter instead, the composer sends typing.stop before the message.

In a thread, the composer calls Channel.keystroke(thread.id) and the events carry parent_id.

A custom input that writes through messageComposer.textComposer sends typing events the same way. An input that doesn't use the composer calls the channel directly:

// On every change of the input: sends typing.start at most every two seconds
await channel.keystroke();

// When the user sends or abandons the message
await channel.stopTyping();

// The same for a thread
await channel.keystroke(thread.id);
await channel.stopTyping(thread.id);

Timing

  • typing.start goes out at most once every two seconds while the user keeps typing.
  • typing.stop goes out when the user hasn't typed for more than a second, and before a message is sent. The client checks every half second.
  • Another member's entry is dropped from channel.state.typing seven seconds after their last typing.start arrived, in case their typing.stop never comes. The client then raises a local typing.stop event.
  • The message list's indicator waits two seconds after the last person stops before it hides. The header subtitles switch back at once.

When typing events are suppressed

Channel.keystroke() and Channel.stopTyping() send nothing when:

  • typing events are off for the channel, by the client configuration or by the channel type, see Turning it on or off;
  • the user's privacy_settings.typing_indicators.enabled is false;
  • the WebSocket connection isn't healthy;
  • the channel has no id from the server yet.

The composer also skips Channel.keystroke() when the text is empty, and when its text.publishTypingEvents setting is false.

Typing in threads

Sending works in threads: a thread composer sends events with parent_id, and every member's channel.state.typing receives them. The one exception is the typing.stop the client sends by itself after a pause: it carries no parent_id. The channel removes the user's entry either way.

Showing them doesn't, in the default components. TypingIndicator and TypingIndicatorHeader read the typing map of the composer they find in context. Inside Thread that is the thread's composer, and only the channel's composer receives typing events. So the indicator in a thread's message list, and the subtitle of ThreadHeader, never show anyone typing.

To show typing in threads, register an indicator that reads channel.state.typing and filters by parent_id. This one works in channels and threads alike:

import { useEffect } from "react";
import type {
  ChannelConfig,
  ChannelStateData,
  EventPayload,
} from "stream-chat";
import {
  useChannel,
  useChatContext,
  useStateStore,
  useThreadContext,
  type TypingIndicatorProps,
} from "stream-chat-react";

const typingSelector = ({ typing }: ChannelStateData) => ({ typing });
const typingEventsSelector = ({ typingEvents }: ChannelConfig) => ({
  typingEventsEnabled: typingEvents.enabled,
});

export const ChannelAndThreadTypingIndicator = ({
  isMessageListScrolledToBottom = true,
  scrollToBottom,
}: TypingIndicatorProps) => {
  const { client } = useChatContext();
  const channel = useChannel();
  const thread = useThreadContext();
  const { typing } = useStateStore(channel.state, typingSelector);
  const { typingEventsEnabled } = useStateStore(
    channel.configState,
    typingEventsSelector,
  );

  const names = (Object.values(typing) as EventPayload<"typing.start">[])
    .filter(({ user }) => user?.id !== client.user?.id)
    // In a thread, its own replies; in the channel, events without a thread
    .filter(({ parent_id }) => (thread ? parent_id === thread.id : !parent_id))
    .map(({ user }) => user?.name || user?.id);

  const visible =
    typingEventsEnabled && isMessageListScrolledToBottom && names.length > 0;

  useEffect(() => {
    if (visible) scrollToBottom?.();
  }, [scrollToBottom, visible]);

  if (!visible) return null;

  return (
    <div className="custom-typing-indicator" role="status">
      {names.join(", ")} {names.length === 1 ? "is" : "are"} typing…
    </div>
  );
};

Register it with <WithComponents overrides={{ TypingIndicator: ChannelAndThreadTypingIndicator }}> around Channel and Thread. A custom thread header can use the same filter for its subtitle, see the Thread header recipe.

Turning it on or off

Typing events are on when both of these allow them:

  • the channel type's typing_events setting on the server, see Typing indicators in the JavaScript client docs;
  • channel.typingEvents.enabled in the client configuration, which defaults to true.

A server "no" wins. The resolved value is channel.config.typingEvents.enabled. When it's false, nothing is sent and the built-in indicators show nothing.

To turn typing events off in the client for every channel:

client.config.set({ channel: { typingEvents: { enabled: false } } });

To keep showing others' typing but stop sending it from some composers, change text.publishTypingEvents on those composers. This setup function turns it off for thread composers:

client.config.setSetupFunction("messageComposer", ({ composer }) => {
  if (composer.threadId) {
    composer.updateConfig({ text: { publishTypingEvents: false } });
  }
});

A user can also stop sharing their typing. With privacy_settings.typing_indicators.enabled: false on their user, the client sends no typing events and the server drops any that reach it:

import { useCreateChatClient } from "stream-chat-react";

export const useClientWithoutTypingEvents = () =>
  useCreateChatClient({
    apiKey,
    tokenOrProvider: userToken,
    userData: {
      id: userId,
      privacy_settings: { typing_indicators: { enabled: false } },
    },
  });

Configuration explains client.config and setup functions.

Customizing

Replace the list's indicator through the TypingIndicator override key. It receives isMessageListScrolledToBottom and scrollToBottom, so it can stay hidden while the user reads older messages and keep the list at the bottom when it appears. Typing in threads has a complete one.

VirtualizedMessageList renders whatever is registered under that key, and nothing when the key is empty. To show the default indicator there, register it:

import type { Channel as ChannelType } from "stream-chat";
import {
  Channel,
  TypingIndicator,
  VirtualizedMessageList,
  WithComponents,
} from "stream-chat-react";

export const VirtualizedChannel = ({ channel }: { channel: ChannelType }) => (
  <WithComponents overrides={{ TypingIndicator }}>
    <Channel channel={channel}>
      <VirtualizedMessageList />
    </Channel>
  </WithComponents>
);

TypingIndicatorHeader is exported for custom headers. It shows typing in the channel, or in the current thread with threadList, which has the limit described in Typing in threads. The Channel header recipe builds a header around it.

The texts come from the typing.singleUser, typing.twoUsers and typing.manyUsers translation keys, see Localization.

Reference