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

Channel header

This UI cookbook builds a channel header of its own: a back button with a badge for the unread messages in the user's other channels, the channel's avatar and name, and a subtitle that shows the connection status, who is typing, or the member and online counts. The SDK has no channel header component; how a header gets onto a channel screen, and the hooks it reads, are explained in Channel UI.

Best practices

  • Render the header as the first child of Channel, and hide the navigator's header for that screen. The header then reads the channel with useChannel(), and Channel starts at the top of the screen, so pass keyboardVerticalOffset={0}. Left out, it's 86.5 on iOS and -300 on Android.
  • Add the top safe area inset to the header's padding, from useSafeAreaInsets(), so the status bar and the notch don't cover it.
  • Wrap the header in PortalWhileClosingView, so a message animating back from the message menu passes under it, not over it.
  • Read the name, the avatar, the counts and typing through the SDK's hooks, which follow the channel's stores. channel.data read during render doesn't update the header.
  • Keep the header light. It re-renders on every typing event, and the badge counts again on every client event.

Implementation

src/ChannelHeader.tsx
import { useEffect, useState } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { Channel, NetworkConnectionState } from "stream-chat";
import {
  BadgeNotification,
  ChannelAvatar,
  ChevronLeft,
  PortalWhileClosingView,
  useChannel,
  useChannelMemberCount,
  useChannelOnlineMemberCount,
  useChannelPreviewDisplayName,
  useChatContext,
  useNetworkConnectionStateSelector,
  useSettledWSConnectionHealth,
  useTheme,
  useTypingString,
} from "stream-chat-react-native";

const isOnlineSelector = ({ isOnline }: NetworkConnectionState) => ({
  isOnline,
});

// The unread messages of every other channel the client holds, except the muted ones.
const useOtherChannelsUnreadCount = (channel: Channel) => {
  const { client } = useChatContext();
  const [count, setCount] = useState(0);

  useEffect(() => {
    const countUnread = () =>
      client.channelManager.values().reduce((total, other) => {
        // A channel that was deleted, or that the user was removed from, is disconnected.
        if (other.cid === channel.cid || other.pendingDisposal) return total;
        if (other.state.getLatestValue().muteStatus.muted) return total;
        return total + other.state.unreadCount;
      }, 0);

    setCount(countUnread());
    // The client applies each event to the channels before it calls its listeners.
    const { unsubscribe } = client.on(() => setCount(countUnread()));
    return unsubscribe;
  }, [channel, client]);

  return count;
};

type ChannelHeaderProps = {
  onBack: () => void;
  onOpenDetails: () => void;
};

export const ChannelHeader = ({
  onBack,
  onOpenDetails,
}: ChannelHeaderProps) => {
  const channel = useChannel();
  const { top } = useSafeAreaInsets();
  const {
    theme: { semantics },
  } = useTheme();
  const name = useChannelPreviewDisplayName(channel);
  const memberCount = useChannelMemberCount(channel);
  const onlineCount = useChannelOnlineMemberCount(channel);
  const typing = useTypingString();
  const unreadElsewhere = useOtherChannelsUnreadCount(channel);
  const { isOnline } =
    useNetworkConnectionStateSelector(isOnlineSelector) ?? {};
  const isConnected = useSettledWSConnectionHealth();

  // `=== false`: `undefined` means the network status isn't known yet.
  const subtitle =
    isOnline === false
      ? "Waiting for network..."
      : !isConnected
        ? "Connecting..."
        : typing || `${memberCount} members, ${onlineCount} online`;

  return (
    <PortalWhileClosingView
      portalHostName="channel-header"
      portalName="channel-header"
    >
      <View
        style={[
          styles.container,
          {
            backgroundColor: semantics.backgroundCoreElevation1,
            borderBottomColor: semantics.borderCoreSubtle,
            paddingTop: top + 8,
          },
        ]}
      >
        <Pressable
          accessibilityLabel={
            unreadElsewhere > 0 ? `Back, ${unreadElsewhere} unread` : "Back"
          }
          accessibilityRole="button"
          hitSlop={8}
          onPress={onBack}
          style={styles.back}
        >
          <ChevronLeft height={24} stroke={semantics.textPrimary} width={24} />
          {unreadElsewhere > 0 ? (
            <View style={styles.badge}>
              <BadgeNotification
                count={unreadElsewhere}
                size="xs"
                type="primary"
              />
            </View>
          ) : null}
        </Pressable>
        <Pressable
          accessibilityLabel="Channel details"
          accessibilityRole="button"
          onPress={onOpenDetails}
          style={styles.details}
        >
          <ChannelAvatar channel={channel} size="lg" />
          <View style={styles.title}>
            <Text
              numberOfLines={1}
              style={[styles.name, { color: semantics.textPrimary }]}
            >
              {name}
            </Text>
            <Text
              numberOfLines={1}
              style={[styles.subtitle, { color: semantics.textSecondary }]}
            >
              {subtitle}
            </Text>
          </View>
        </Pressable>
      </View>
    </PortalWhileClosingView>
  );
};

const styles = StyleSheet.create({
  back: {
    alignItems: "center",
    height: 40,
    justifyContent: "center",
    width: 40,
  },
  badge: { left: 22, position: "absolute", top: 2 },
  container: {
    alignItems: "center",
    borderBottomWidth: 1,
    flexDirection: "row",
    gap: 4,
    paddingBottom: 8,
    paddingHorizontal: 8,
  },
  details: { alignItems: "center", flex: 1, flexDirection: "row", gap: 8 },
  name: { fontSize: 16, fontWeight: "600" },
  subtitle: { fontSize: 13 },
  title: { flex: 1 },
});
A channel header with a back button, an unread badge, the channel name and member counts on iOS
A channel header with a back button, an unread badge, the channel name and member counts on Android

Here is what each part reads:

  • useChannelPreviewDisplayName(channel) gives the name the channel list shows: the channel's name, or its other members' names when it has none. ChannelAvatar shows the channel's image, or the members' avatars, with the online dot in a direct message.
  • useChannelMemberCount(channel) reads memberCount from channel.state. useChannelOnlineMemberCount(channel) reads watcherCount, the number of users watching the channel right now.
  • useTypingString() returns "Cleo is typing", or "Cleo and 2 more are typing", for the people typing in the channel itself, and an empty string while nobody is. It leaves out the current user and the replies typed in threads. It reads the channel from Channel, so the header has to be inside it.
  • useNetworkConnectionStateSelector reads whether the device has a network, and useSettledWSConnectionHealth() whether the connection to Stream is up. A drop of the connection shorter than five seconds doesn't count, so a short reconnect doesn't flash "Connecting...". Connection status explains both.

The unread badge

The badge counts the messages the user hasn't read in other channels, so it stays useful after they leave this one. No hook returns that number, so useOtherChannelsUnreadCount adds it up:

  • It sums channel.state.unreadCount, the user's unread count in each channel, over the channels in client.channelManager: the channels the client has loaded, such as the ones in your channel lists. Channels the client hasn't loaded don't count.
  • It leaves out this channel and the muted ones, as the channel list's badges do. It also leaves out a channel that was deleted or that the user was removed from: the client disconnects it and keeps it until its listeners have run.
  • It counts again on every client event. The client updates a channel's state before it calls the listeners, so the count includes the event's message or read.

For a total over all the user's channels, loaded or not, read total_unread_count from the server: client.user?.total_unread_count after connecting, and the same field on the events that carry it, such as notification.message_new and notification.mark_read. That total includes this channel while its new messages wait to be marked read.

Final code

The channel screen hides the navigator's header and renders ChannelHeader above the list:

src/ChannelScreen.tsx
import type { NativeStackScreenProps } from "@react-navigation/native-stack";
import {
  Channel,
  MessageComposer,
  MessageList,
  useChatContext,
} from "stream-chat-react-native";
import { ChannelHeader } from "./ChannelHeader";

type StackParamList = {
  Channel: { cid: string };
  ChannelDetails: { cid: string };
  Thread: { threadId: string };
};

export const ChannelScreen = ({
  navigation,
  route,
}: NativeStackScreenProps<StackParamList, "Channel">) => {
  const { client } = useChatContext();
  const channel = client.channelManager.get(route.params.cid);

  if (!channel) return null;

  return (
    <Channel channel={channel} keyboardVerticalOffset={0}>
      <ChannelHeader
        onBack={() => navigation.goBack()}
        onOpenDetails={() =>
          navigation.navigate("ChannelDetails", { cid: channel.cid })
        }
      />
      <MessageList
        onThreadSelect={(thread) =>
          navigation.navigate("Thread", { threadId: thread.id })
        }
      />
      <MessageComposer />
    </Channel>
  );
};

Register the screen with options={{ headerShown: false }}. The header is part of the screen's views, so Channel gets a keyboardVerticalOffset of 0 and no topInset; see Keyboard and insets. The details screen the avatar opens is on Channel UI, and the thread screen on Navigation.

A thread screen can use the same header pattern inside Thread. There, useTypingString() counts only the people typing in that thread, and useChannel() returns the thread's channel. The Thread header UI cookbook builds one.