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()andChannel.stopTyping()sendtyping.startandtyping.stopevents. When another member's event arrives, the channel stores it inchannel.state.typing, keyed by user id. - UI. The text composer calls
Channel.keystroke()as the user types.MessageListrendersTypingIndicatorat the bottom of the list.ChannelHeaderandThreadHeaderrenderTypingIndicatorHeaderin their subtitle.VirtualizedMessageListrenders 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
Example
The user types "Hello" in the composer:
- Their first keystroke calls
Channel.keystroke(), which sendstyping.start. - Their next keystrokes call it again, but it sends nothing until two seconds have passed since the last
typing.start. - They stop typing. A second later, the client sends
typing.stopby itself. - If they press Enter instead, the composer sends
typing.stopbefore 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.startgoes out at most once every two seconds while the user keeps typing.typing.stopgoes 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.typingseven seconds after their lasttyping.startarrived, in case theirtyping.stopnever comes. The client then raises a localtyping.stopevent. - 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.enabledisfalse; - 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_eventssetting on the server, see Typing indicators in the JavaScript client docs; channel.typingEvents.enabledin the client configuration, which defaults totrue.
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>
);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
- Indicators: the
TypingIndicatorprops. - ComponentContext: the
TypingIndicatoroverride key. - Channel and channel state:
channel.state.typing,keystrokeandstopTyping. - Configuration tree:
channel.typingEventsandmessageComposer.text.publishTypingEvents.