Thread header

This recipe builds a thread header of its own. The default header is explained in Thread UI.

Note:

What you'll build

  • A thread header with the participants' avatars
  • The channel name and reply count in the subtitle
  • Who is typing a reply in the subtitle

Preview

A custom thread header with the avatars of the participants, the title Thread, the channel name and the reply count

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

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

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

Best practices

  • Read the thread from useThreadContext() and subscribe to thread.state for the reply count and the participants. Declare the selectors at module scope.
  • Take the channel's name from useChannelPreviewInfo(), as the default header does.
  • Keep the back and close buttons, WorkspaceNavigationBackButton and WorkspaceNavigationCloseButton. They render only when the layout allows going back or closing the thread's panel. A button of your own closes the thread with useCloseThread().
  • Show typing with <TypingIndicatorHeader threadList />. With threadList, it shows the users typing a reply in this thread, not in the channel.
  • Hide the typing text when typing events are off for the channel, in its configuration (channel.configState).
  • Keep the header compact, so the replies keep the space.

Create the component

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

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

export const CustomThreadHeader = () => {
  const { client } = useChatContext();
  const channel = useChannel();
  const thread = useThreadContext();
  const { participants = [], replyCount = 0 } =
    useStateStore(thread?.state, threadStateSelector) ?? {};
  const { typing } = useStateStore(channel.state, typingSelector);
  const { typingEventsEnabled } = useStateStore(
    channel.configState,
    typingEventsSelector,
  );
  const { displayTitle } = useChannelPreviewInfo({ channel });

  // Others typing a reply in this thread. Typing events carry the thread's id as parent_id.
  const someoneIsTyping = (
    Object.values(typing) as EventPayload<"typing.start">[]
  ).some(
    ({ parent_id, user }) =>
      parent_id === thread?.id && user?.id !== client.userId,
  );

  return (
    <div className="cookbook-thread-header">
      <WorkspaceNavigationBackButton />
      <div className="cookbook-thread-header__participants">
        {participants.map(({ user }) =>
          user ? (
            <Avatar
              imageUrl={user.image}
              key={user.id}
              size="sm"
              userName={user.name ?? user.id}
            />
          ) : null,
        )}
      </div>
      <div className="cookbook-thread-header__content">
        <div className="cookbook-thread-header__title">Thread</div>
        <div className="cookbook-thread-header__subtitle">
          {typingEventsEnabled && someoneIsTyping ? (
            <TypingIndicatorHeader threadList />
          ) : (
            `${displayTitle} · ${replyCount} ${replyCount === 1 ? "reply" : "replies"}`
          )}
        </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:

  • useThreadContext() returns the thread that Thread or ThreadSlot renders. thread.state holds the participants, each with a user, and the reply count. What channels and threads hold explains the rest.
  • useChannel() returns the thread's channel, so useChannelPreviewInfo() gives its name.
  • channel.state.typing holds the latest typing event of each user in the channel. An event sent from a thread carries the thread's id as parent_id, so the header filters on it to decide whether to show the typing text. The filter is the one from Typing in threads.
  • 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 with threadList renders "Bob is typing", "Bob and Cara are typing" or "3 people are typing" from the same events, translated like the rest of the SDK.

The reply count text here is plain English. To translate it, use t from useTranslationContext(), as Localization explains.

Render it in the thread

Put the header where ThreadHeader would go. With ChatView, that's inside ThreadSlot:

src/ThreadPanel.tsx
import {
  MessageComposer,
  MessageList,
  useThreadContext,
} from "stream-chat-react";
import { ThreadSlot, useActiveThread } from "stream-chat-react/slot-layout";
import { CustomThreadHeader } from "./CustomThreadHeader";

// Marks the thread as on screen, so its new replies are marked read.
const ActiveThread = () => {
  const thread = useThreadContext();
  useActiveThread({ activeThread: thread });
  return null;
};

export const ThreadPanel = () => (
  <ThreadSlot slot="thread">
    <ActiveThread />
    <CustomThreadHeader />
    <MessageList />
    <MessageComposer />
  </ThreadSlot>
);

ActiveThread tells the thread it's on screen, so its new replies are marked read, as in Channel and thread side by side. Render ThreadPanel in place of the base app's ThreadSlot. Without a layout, render the header inside <Thread thread={thread}> the same way.

How it works

Info:

Example

The user opens the thread under Bob's message in Design review, and Cara starts typing a reply:

  1. ThreadSlot renders the thread, and CustomThreadHeader reads it with useThreadContext(). The header shows the participants' avatars from thread.state.participants, and the subtitle from the channel's name and thread.state.replyCount, such as "Design review · 3 replies".
  2. Cara starts typing in the thread. Her typing event carries the thread's id as parent_id, and lands in channel.state.typing.
  3. someoneIsTyping is true, so the subtitle shows <TypingIndicatorHeader threadList />: "Cara is typing".
  4. Cara sends her reply. Her typing entry goes, thread.state.replyCount goes up, and the subtitle reads "Design review · 4 replies".

Typing in the channel itself carries no parent_id, so it doesn't change this header.