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.
  • 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 The indicator in the virtualized list.
  • 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

The default indicators filter channel.state.typing by parent_id, so each one shows the typing of the list it sits in: the channel's, or one thread's. Typing indicator in Channel UI shows what each indicator displays in a <Channel/> and in a <Thread/>.

The typing.stop the client sends by itself after a pause carries no parent_id. The channel removes the user's entry either way.

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, set text.publishTypingEvents to false on those composers with a messageComposer setup function. Setup functions shows one that turns it off for thread composers.

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

A typing indicator of your own

Register a component under the TypingIndicator key. It receives isMessageListScrolledToBottom and scrollToBottom: hide it while the user reads older messages, and scroll down when it appears. This TextTypingIndicator shows the names as plain text:

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

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

export const TextTypingIndicator = ({
  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)
    // Inside a thread, its 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>
  );
};

const overrides = { TypingIndicator: TextTypingIndicator };

// Wrap <Channel/> and <Thread/> in it, so both message lists use the indicator.
export const WithTextTypingIndicator = ({ children }: PropsWithChildren) => (
  <WithComponents overrides={overrides}>{children}</WithComponents>
);

The indicator in the virtualized list

<VirtualizedMessageList/> shows a typing indicator only when one is registered under the TypingIndicator key. To show the default one, 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>
);
Note:

Don't register it for a livestream or another channel with many messages and members. There, typing events add traffic for every member, and the indicator rarely shows anything readable. Turning off typing, presence and delivery receipts in Livestream chat turns them off.

The indicator in a header of your own

<TypingIndicatorHeader/> is exported for custom headers. It shows typing in the channel. With its threadList prop set, it shows typing in the thread around it instead. The Channel header and Thread header recipes build headers around it.

The texts

Both indicators take their text from these translation keys:

Key Default text Shown when
typing.singleUser {{ typing }} is typing one person is typing
typing.twoUsers {{ firstUser }} and {{ secondUser }} are typing two people are typing
typing.manyUsers_one, typing.manyUsers_other {{ count }} person is typing, {{ count }} people are typing three or more people are typing, or one of them has no name

typing.twoUsers gets each name as its own value, firstUser and secondUser, so a translation words the whole sentence, including the word between the names. A German dictionary, for example:

import { Chat, Streami18n } from "stream-chat-react";

const streamI18n = new Streami18n({ language: "de" });

streamI18n.registerTranslation("de", {
  "typing.twoUsers": "{{ firstUser }} und {{ secondUser }} schreiben",
});

export const App = () => (
  <Chat client={client} i18nInstance={streamI18n}>
    {/* the rest of the app */}
  </Chat>
);

Localization explains how to register dictionaries and change the other texts.

Reference