Channel header

This recipe builds a channel header of its own. The default header and its props are explained in Channel UI. To change only the title or the subtitle, you don't need a header of your own: pass a component as the header's Title or Subtitle, as that section shows.

Note:

What you'll build

  • A channel header with the channel's avatar and title
  • Member and online counts in the subtitle
  • Who is typing in the subtitle

Preview

A custom channel header with the channel avatar, its title, and the member and online counts

Approach: Compose. Choosing where to start explains the approaches.

Starts from: the base app. The page shows only what the recipe adds.

ChannelHeader has no override key. Channel renders only its children, so a header of your own goes where ChannelHeader would.

Best practices

  • Read the title, image and group avatar from useChannelPreviewInfo(). It follows changes to the channel's name, image and members.
  • Read counts and typing from channel.state with useStateStore, and declare the selectors at module scope.
  • Show typing with TypingIndicatorHeader, the header's "Bob is typing" text, not the message list's TypingIndicator.
  • Hide the typing text when typing events are off for the channel, in its configuration (channel.configState).
  • Keep the back and close buttons, WorkspaceNavigationBackButton and WorkspaceNavigationCloseButton. They render only when the layout allows going back or closing the panel.
  • Keep the header light: it re-renders on every typing event.

Implementation

The default header switches its subtitle between typing and the online status. This one does the same:

src/CustomChannelHeader.tsx
import type {
  ChannelConfig,
  ChannelStateData,
  EventPayload,
} from "stream-chat";
import {
  ChannelAvatar,
  TypingIndicatorHeader,
  useChannel,
  useChannelPreviewInfo,
  useChatContext,
  useStateStore,
  WorkspaceNavigationBackButton,
  WorkspaceNavigationCloseButton,
} from "stream-chat-react";
import "./CustomChannelHeader.css";

const channelStateSelector = ({
  memberCount,
  typing,
  watcherCount,
}: ChannelStateData) => ({
  memberCount,
  typing,
  watcherCount,
});

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

export const CustomChannelHeader = () => {
  const { client } = useChatContext();
  const channel = useChannel();
  const { memberCount, typing, watcherCount } = useStateStore(
    channel.state,
    channelStateSelector,
  );
  const { typingEventsEnabled } = useStateStore(
    channel.configState,
    typingEventsSelector,
  );
  const { displayImage, displayTitle, groupChannelDisplayInfo } =
    useChannelPreviewInfo({
      channel,
    });

  // Others typing in the channel itself; replies typed in threads carry a parent_id.
  const someoneIsTyping = (
    Object.values(typing) as EventPayload<"typing.start">[]
  ).some(({ parent_id, user }) => !parent_id && user?.id !== client.userId);

  return (
    <div className="cookbook-channel-header">
      <WorkspaceNavigationBackButton />
      <ChannelAvatar
        displayMembers={groupChannelDisplayInfo.members}
        imageUrl={displayImage}
        size="lg"
        userName={displayTitle}
      />
      <div className="cookbook-channel-header__content">
        <div className="cookbook-channel-header__title">{displayTitle}</div>
        <div className="cookbook-channel-header__subtitle">
          {typingEventsEnabled && someoneIsTyping ? (
            <TypingIndicatorHeader />
          ) : (
            `${memberCount} members · ${watcherCount} online`
          )}
        </div>
      </div>
      <WorkspaceNavigationCloseButton />
    </div>
  );
};

The rules sit in the stream-overrides layer, so they win over the SDK's own rules once the SDK's stylesheet is in an earlier layer, as Theming → CSS layers sets up. They take their colors, spacing and type from the SDK's tokens, so they follow your theme.

Here is what each part reads:

  • useChannelPreviewInfo({ channel }) gives the title and image the channel list shows: the channel's name, or its members' names when it has none. groupChannelDisplayInfo.members are the members ChannelAvatar draws when the channel has no image. It works out the overflow badge itself.
  • channel.state holds memberCount, watcherCount, the number of users watching the channel right now, and typing, the latest typing event of each user. What channels and threads hold explains the rest.
  • channel.configState holds the channel's resolved configuration. typingEvents.enabled is false when the client configuration or the channel type turns typing events off.
  • TypingIndicatorHeader renders "Bob is typing", "Bob and Cara are typing" or "3 people are typing" from the same events. Typing indicators covers typing end to end.

While a member types

The same header while another member types: the subtitle reads "Bob is typing"

To open channel details from the avatar, render AvatarWithChannelDetail from stream-chat-react/channel-detail in place of ChannelAvatar. See Channel details UI.

Final code

Render the header where ChannelHeader would go. With ChatView, that's inside ChannelSlot:

src/App.tsx
import {
  ChannelNavigation,
  Chat,
  MessageComposer,
  MessageList,
  ThreadHeader,
} from "stream-chat-react";
import {
  ChannelSlot,
  ChatView,
  ThreadSlot,
} from "stream-chat-react/slot-layout";
import type { LayoutDescriptor } from "stream-chat-react/slot-layout";
import { CustomChannelHeader } from "./CustomChannelHeader";

const layouts: LayoutDescriptor[] = [
  { id: "channels", slots: ["channel", "thread"] },
];

const ChannelsView = () => (
  <>
    <ChannelNavigation />
    <ChannelSlot slot="channel">
      <CustomChannelHeader />
      <MessageList />
      <MessageComposer />
    </ChannelSlot>
    <ThreadSlot slot="thread">
      <ThreadHeader />
      <MessageList />
      <MessageComposer />
    </ThreadSlot>
  </>
);

export const App = () => (
  <Chat client={client}>
    <ChatView layouts={layouts} views={{ channels: <ChannelsView /> }} />
  </Chat>
);

Without a layout, render the header inside <Channel channel={channel}> the same way.

How it works

Info:

Example

The user has Design review open, and Bob starts typing:

  1. CustomChannelHeader shows the channel's title and image from useChannelPreviewInfo(), and the subtitle from channel.state.memberCount and channel.state.watcherCount, such as "4 members · 2 online".
  2. Bob starts typing. His typing event arrives, channel.state.typing gets an entry for him, and useStateStore renders the header again.
  3. The event has no parent_id and isn't the user's own, so someoneIsTyping is true. The subtitle shows TypingIndicatorHeader: "Bob is typing".
  4. Bob stops typing. His entry leaves channel.state.typing, and the subtitle shows the counts again.

A reply Bob types in a thread carries the thread's id as parent_id, so it doesn't change this header. When channel.configState says typing events are off, the subtitle always shows the counts.