Channel UI
An open channel is a screen of parts: a header, a message list and a composer. Channel gives one channel to every part inside it, moves them out of the keyboard's way, and hosts the attachment picker. The parts are yours to choose. This page covers Channel itself, keyboard handling, the configuration it shares with Thread, the typing indicator, the channel header and channel details. The props are in the Channel reference.
The Channel component
<Channel channel={channel}> binds a channel to its children. It renders, from the outside in:
KeyboardCompatibleView, which resizes the content when the keyboard opens. See Keyboard and insets.- The contexts its children read: the channel, the user's capabilities in it, the message and composer configuration, the composer, and the channel's own audio players. How components connect to state explains them.
- Your children.
- The attachment picker, a bottom sheet that opens from the composer. See Message composer UI.
When it mounts, and again whenever you pass it a different channel, Channel:
- Sets the page size of the channel's message list to 25 messages, unless you registered one in
client.config(see Message paginators). - Watches the channel, unless the channel is already watched and loaded with its latest messages. A channel from a channel list opens without a request. With
initializeOnMount={false}it doesn't watch, and you callChannel.watch()yourself. - Loads the latest messages when the message list holds none, for example for a channel opened from a push notification.
- Takes the channel's unread snapshot from its current read state, which the unread separator and the unread notification render from. A channel the user opened before keeps its loaded messages, so this is what keeps them right when the user comes back to it.
- With
initialScrollToFirstUnreadMessage, jumps to the first unread message, unless something already jumped the list to a message. - Marks the channel read when it has unread messages, unless
markReadOnMountisfalse. The unread snapshot stays, so the separator still shows where the new messages start.
While it's mounted, Channel also:
- activates the channel, so the client keeps it loaded and reloads it after a reconnect. Entity lifecycle explains activation.
- marks the channel read after a reconnect, when the reloaded list shows the latest messages.
- sends a
typing.stopevent when the app goes to the background while the user is typing. - renders the replacement when the channel you passed is superseded by a new object for the same conversation (see Superseded channels).
When it renders something else
| Case | What Channel renders |
Override key |
|---|---|---|
| The message list has no messages and its query failed | "Error loading messages for this channel...". Tapping it loads the latest messages again. | LoadingErrorIndicator |
The channel has no cid |
"Please select a channel first" | None |
The error state replaces everything inside Channel, including a header you render as a child. A screen that must keep its header in that state renders the header outside Channel.
Keyboard and insets
Channel wraps its content in KeyboardCompatibleView, which adds padding at the bottom while the keyboard is open, so the composer stays above it. To place the composer right, it needs to know where Channel sits on the screen, and the attachment picker needs to know how far up it may open:
| Prop | Set it to |
|---|---|
keyboardVerticalOffset |
The distance from the top of the screen to the top of Channel: the navigation header's height, such as useHeaderHeight() from React Navigation, or 0 when Channel starts at the top of the screen |
topInset |
The navigation header's height, when the screen has a native header, so the attachment picker stops below it. Leave it at 0 otherwise. |
bottomInset |
The height of anything below Channel on the screen, such as a tab bar. 0 by default. |
Set keyboardVerticalOffset on every screen, 0 included: when you leave it out, Channel falls back to a fixed 86.5 points on iOS. Use the same value everywhere the same layout renders.
Three more props change the keyboard handling. keyboardBehavior picks how the view makes room: 'padding' (the default), 'height' or 'position', as in React Native's KeyboardAvoidingView. additionalKeyboardAvoidingViewProps passes other props to the view. disableKeyboardCompatibleView turns it off, for an app that moves the content itself, for example with react-native-keyboard-controller.
Keyboard and insets covers layouts with headers, tab bars and edge-to-edge Android, and Chat in modals covers channels in a modal.
Configuration shared with Thread
Besides the props of its own, Channel takes configuration for the parts below it. Thread takes the same configuration, and neither passes it to the other, so a channel screen and a thread screen each need it.
| Group | Examples |
|---|---|
| Message list | hideDateSeparators, hideStickyDateHeader, enableMessageGroupingByUser, maxTimeBetweenGroupedMessages, FlatList |
| Messages | messageActions, supportedReactions, reactionListPosition, messageContentOrder, onPressMessage, onLongPressMessage, the handle* handlers, myMessageTheme |
| Composer | audioRecordingEnabled, hasCommands, hasCreatePoll, messageInputFloating, additionalTextInputProps, setInputRef |
| Attachment picker | topInset, bottomInset, disableAttachmentPicker, hasCameraPicker, hasFilePicker, hasImagePicker |
| Keyboard | keyboardVerticalOffset, keyboardBehavior, disableKeyboardCompatibleView |
| Capabilities | overrideOwnCapabilities |
The props that only Channel takes are about loading the channel: initializeOnMount, markReadOnMount and initialScrollToFirstUnreadMessage. Thread's own props are on Thread UI.
One way to keep the two in step is a hook that returns the shared props, spread on both:
import { useHeaderHeight } from "@react-navigation/elements";
import type {
Channel as StreamChannel,
Thread as StreamThread,
} from "stream-chat";
import {
Channel,
MessageComposer,
MessageList,
Thread,
} from "stream-chat-react-native";
// The configuration the channel screen and the thread screen share.
const useSharedChatProps = () => {
const headerHeight = useHeaderHeight();
return {
audioRecordingEnabled: true,
keyboardVerticalOffset: headerHeight,
topInset: headerHeight,
};
};
export const ChannelScreen = ({ channel }: { channel: StreamChannel }) => {
const sharedProps = useSharedChatProps();
return (
<Channel channel={channel} {...sharedProps}>
<MessageList />
<MessageComposer />
</Channel>
);
};
export const ThreadScreen = ({ thread }: { thread: StreamThread }) => {
const sharedProps = useSharedChatProps();
return (
<Thread thread={thread} {...sharedProps}>
<MessageList />
<MessageComposer />
</Thread>
);
};Typing indicator
The channel keeps the latest typing event of each user in channel.state.typing. Three places show it:
TypingIndicatorrenders at the newest end of the message list: the avatars of the people typing, up to three, and animated dots. It's part of the list's content, so it shows when the user scrolls to the latest message.TypingIndicatorContaineraround it renders nothing while nobody is typing.disableTypingIndicatoronChannelorThreadturns it off.- The channel list row replaces its message line with who is typing, through
ChannelPreviewTypingIndicator. See Channel list UI. useTypingString()returns the text "Cleo is typing", or "Cleo and 2 more are typing", for a header of your own, and an empty string while nobody is typing.
They leave out the current user, and they keep the channel's typing and the threads' typing apart. In a Channel, they count the events typed in the channel itself. Inside a Thread, they count only the events typed in that thread.
AITypingIndicatorView shows a different state: an AI's reply being thought about or generated in the channel. The SDK doesn't place it; render it above the composer when your channel has an AI participant. See SDK integration.
Typing indicators covers the feature end to end: sending typing events and building an indicator of your own.
The channel header
The SDK has no channel header component. A channel screen gets its header one of two ways:
- From the navigator. Set the native header's title, and its buttons, through the screen's options. Pass the header's height to
ChannelaskeyboardVerticalOffsetandtopInset, as in Quick start. - As the first child of
Channel. Hide the navigator's header and render your own aboveMessageList.Channelthen starts at the top of the screen, sokeyboardVerticalOffsetis0andtopInsetstays unset. InsideChannel, the header reads the channel withuseChannel()and can show who is typing.
The SDK's hooks give a header what it shows: useChannelPreviewDisplayName(channel) for the name, ChannelAvatar for the image, useChannelMemberCount(channel) and useChannelOnlineMemberCount(channel) for the counts, and useTypingString() for typing. They read the channel's stores, so the header updates as the channel changes. This header renders as the first child of Channel:
import { Pressable, StyleSheet, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
ChannelAvatar,
ChevronLeft,
PortalWhileClosingView,
useChannel,
useChannelMemberCount,
useChannelOnlineMemberCount,
useChannelPreviewDisplayName,
useTypingString,
} from "stream-chat-react-native";
type ChannelHeaderProps = {
onBack: () => void;
onOpenDetails: () => void;
};
export const ChannelHeader = ({
onBack,
onOpenDetails,
}: ChannelHeaderProps) => {
const channel = useChannel();
const { top } = useSafeAreaInsets();
const name = useChannelPreviewDisplayName(channel);
const memberCount = useChannelMemberCount(channel);
const onlineCount = useChannelOnlineMemberCount(channel);
const typing = useTypingString();
return (
// Keeps the header above a message that animates back when the message menu closes.
<PortalWhileClosingView
portalHostName="channel-header"
portalName="channel-header"
>
<View style={[styles.container, { paddingTop: top }]}>
<Pressable
accessibilityLabel="Back"
accessibilityRole="button"
onPress={onBack}
>
<ChevronLeft height={24} width={24} />
</Pressable>
<View style={styles.title}>
<Text numberOfLines={1} style={styles.name}>
{name}
</Text>
<Text numberOfLines={1}>
{typing || `${memberCount} members, ${onlineCount} online`}
</Text>
</View>
<Pressable
accessibilityLabel="Channel details"
accessibilityRole="button"
onPress={onOpenDetails}
>
<ChannelAvatar channel={channel} size="lg" />
</Pressable>
</View>
</PortalWhileClosingView>
);
};
const styles = StyleSheet.create({
container: {
alignItems: "center",
flexDirection: "row",
gap: 8,
paddingBottom: 8,
paddingHorizontal: 12,
},
name: { fontSize: 16, fontWeight: "600" },
title: { flex: 1 },
});PortalWhileClosingView matters for a header inside the screen. When the message menu closes, the message animates back into the list, and the wrapper keeps the header drawn above it. The SDK wraps the composer the same way. Overlays and bottom sheets explains the message overlay.
The Channel header UI cookbook adds an unread badge on the back button and a connection status.
Channel details
Channel details is a screen with the channel's profile, its members, links to the pinned messages, photos and videos, and files, and the channel's actions, such as mute and leave. ChannelDetails renders it for the channel of the ChannelDetailsContextProvider around it. It takes onBack, which its back button calls.
The SDK doesn't open it. Give it a screen of its own, and navigate to it from your header, such as the avatar in the header above:
import type { NativeStackScreenProps } from "@react-navigation/native-stack";
import {
ChannelDetails,
ChannelDetailsContextProvider,
useChatContext,
} from "stream-chat-react-native";
type StackParamList = {
ChannelDetails: { cid: string };
};
export const ChannelDetailsScreen = ({
navigation,
route,
}: NativeStackScreenProps<StackParamList, "ChannelDetails">) => {
const { client } = useChatContext();
const channel = client.channelManager.get(route.params.cid);
if (!channel) return null;
return (
<ChannelDetailsContextProvider channel={channel}>
<ChannelDetails onBack={() => navigation.goBack()} />
</ChannelDetailsContextProvider>
);
};The rows of the navigation section, such as pinned messages, open their list in a full-screen modal. To push a screen of your own instead, override ChannelDetailsNavigationSection and pass it getNavigationItems. The actions in channel details are action sets, covered in Actions. Channel details and members covers the screen and its parts, and the props are in the ChannelDetails reference.
Replacing parts
Channel renders its children, so you replace a part of the channel screen by rendering a different component in its place: a header of your own, MessageFlashList instead of MessageList, or a composer of your own.
The parts on this page that render from an override key:
| Override key | Replaces |
|---|---|
KeyboardCompatibleView |
The view that makes room for the keyboard. Gets behavior, enabled, keyboardVerticalOffset and the extra props. |
LoadingErrorIndicator |
The error state above, and the first-page error of the channel list |
TypingIndicator, TypingIndicatorContainer |
The typing indicator in the message list, and the container that hides it while nobody is typing |
ChannelDetailsContent |
The contents of ChannelDetails |
The message list, the composer and the attachment picker have keys of their own, listed on Message list UI and Message composer UI. Overriding components explains how overrides apply.