Threads view

Many chat apps have a Threads view: every thread the user takes part in, in one list, in the place of the channel list. This recipe adds that view to the base app.

Note:

What you'll build

  • A rail to switch between Channels and Threads
  • Thread rows with the first message, latest reply and reply count
  • Unread badges on threads with new replies
  • The picked thread opened beside the list

Preview

The threads view: a button rail on the left, the list of the user's threads with two unread threads on top, and "No thread selected" beside it

Approach: Compose and Replace one part. Choosing where to start explains the approaches.

Starts from: the base app. The page shows only what the recipe adds: the threads view, its row, and the changes to the base app's ChatView.

Best practices

  • Render ThreadList once, in the threads view. Each ThreadList reloads client.threads when it mounts and deactivates it when it unmounts, so a second list would switch off the first one's live updates.
  • Replace the row through the ThreadListItemUI override key and keep ThreadListItem, which gives each row its thread through useThreadListItemContext().
  • Keep the row a button with role="option", aria-selected and data-thread-id. ThreadList is a listbox, and its arrow-key navigation finds rows by those attributes.
  • Read the unread count from the thread, not the channel: thread.state.read[userId].unreadMessageCount. Replies don't count toward the channel's unread count.
  • Keep the row light. The list is virtualized, so rows mount and unmount as the user scrolls.

A threads view

The row

ThreadRow reads the row's thread with useThreadListItemContext() and two of its stores with useStateStore: thread.state for the channel, the parent message, the participants, the reply count and the read state, and thread.messagePaginator.aggregateState for the latest reply. Unread threads get a badge with the number of unread replies, and a parent message in bold.

src/ThreadRow.tsx
import { useCallback, useEffect } from "react";
import type { MessagePaginatorAggregateState, ThreadState } from "stream-chat";
import type { ThreadListItemUIProps } from "stream-chat-react";
import {
  AvatarStack,
  Badge,
  useChannelPreviewInfo,
  useChatContext,
  useStateStore,
  useThreadListItemContext,
  useWorkspaceNavigation,
} from "stream-chat-react";
import "./ThreadRow.css";

const latestReplySelector = ({
  latestMessage,
}: MessagePaginatorAggregateState) => ({
  latestReply: latestMessage,
});

/**
 * One row of the thread list: the channel, the message that started the thread, its latest reply,
 * who takes part, and how many replies the user hasn't read.
 */
export const ThreadRow = ({ resetHighlighting }: ThreadListItemUIProps) => {
  const { client } = useChatContext();
  // ThreadListItem always provides the row's thread.
  const thread = useThreadListItemContext()!;
  const { isThreadActive, openThread } = useWorkspaceNavigation();

  const threadSelector = useCallback(
    (state: ThreadState) => ({
      channel: state.channel,
      parentMessage: state.parentMessage,
      participants: state.participants,
      replyCount: state.replyCount,
      unreadCount: state.read[client.userId ?? ""]?.unreadMessageCount ?? 0,
    }),
    [client],
  );
  const { channel, parentMessage, participants, replyCount, unreadCount } =
    useStateStore(thread.state, threadSelector);
  const { latestReply } = useStateStore(
    thread.messagePaginator.aggregateState,
    latestReplySelector,
  );
  const { displayTitle } = useChannelPreviewInfo({ channel });

  const participantInfo = (participants ?? []).map(({ user }) => ({
    id: user?.id,
    imageUrl: user?.image,
    userName: user?.name ?? user?.id,
  }));
  const isActive = isThreadActive(thread.id);

  // A thread that a reload just brought into the list is highlighted for two seconds.
  useEffect(() => {
    if (!resetHighlighting) return;
    const timeout = setTimeout(resetHighlighting, 2000);
    return () => clearTimeout(timeout);
  }, [resetHighlighting]);

  const className = [
    "cookbook-thread-row",
    unreadCount > 0 && "cookbook-thread-row--unread",
    resetHighlighting && "cookbook-thread-row--new",
  ]
    .filter(Boolean)
    .join(" ");

  return (
    // role, aria-selected and data-thread-id: what ThreadList's arrow-key navigation looks for.
    <button
      aria-selected={isActive}
      className={className}
      data-thread-id={thread.id}
      onClick={(event) =>
        openThread(thread, { additive: event.ctrlKey || event.metaKey })
      }
      role="option"
      type="button"
    >
      <span className="cookbook-thread-row__header">
        <span className="cookbook-thread-row__channel"># {displayTitle}</span>
        {unreadCount > 0 && (
          <Badge size="sm" variant="primary">
            {unreadCount}
          </Badge>
        )}
      </span>
      <span className="cookbook-thread-row__parent">
        <span className="cookbook-thread-row__author">
          {parentMessage.user?.name ?? parentMessage.user?.id}
        </span>
        {parentMessage.text}
      </span>
      {latestReply && (
        <span className="cookbook-thread-row__latest-reply">
          <span className="cookbook-thread-row__author">
            {latestReply.user?.name ?? latestReply.user?.id}
          </span>
          {latestReply.text}
        </span>
      )}
      <span className="cookbook-thread-row__footer">
        <AvatarStack displayInfo={participantInfo} size="sm" />
        {replyCount === 1 ? "1 reply" : `${replyCount} replies`}
      </span>
    </button>
  );
};

The view

ThreadsView is the view's content: the ThreadList with ThreadRow registered for the ThreadListItemUI key, and a ThreadSlot for the thread the user picks. Until the user picks one, the slot renders its fallback, the SDK's ChatViewEmptyPlaceholder, which says "No thread selected" in this view. ActiveThread is the same as in the base app's channels view: it tells the open thread it's on screen, so its new replies are marked read.

src/ThreadsView.tsx
import {
  MessageComposer,
  MessageList,
  ThreadHeader,
  ThreadList,
  useThreadContext,
  WithComponents,
} from "stream-chat-react";
import {
  ChatViewEmptyPlaceholder,
  ThreadSlot,
  useActiveThread,
} from "stream-chat-react/slot-layout";
import { ThreadRow } from "./ThreadRow";
import "./ThreadsView.css";

// Declared once, so WithComponents gets the same object on every render.
const overrides = { ThreadListItemUI: ThreadRow };

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

/** The user's threads in place of the channel list, and the one picked beside them. */
export const ThreadsView = () => (
  <>
    <WithComponents overrides={overrides}>
      <ThreadList />
    </WithComponents>
    <ThreadSlot fallback={<ChatViewEmptyPlaceholder />} slot="thread">
      <ActiveThread />
      <ThreadHeader />
      <MessageList />
      <MessageComposer />
    </ThreadSlot>
  </>
);

Adding the view to ChatView

In the base app, give ChatView a threads layout and the threads view, and render ChatView.Selector as its child. Workspace takes the place of the base app's ChatView in App. This block is base-app setup: ChannelsView is the base app's channels view, moved unchanged into a file of its own, and SIDE_PANEL_SLOT comes from its side panel. The cookbook app adds the view through its recipe config instead, so this file isn't in the recipe's folder:

src/App.tsx
import { ChatView } from "stream-chat-react/slot-layout";
import type { LayoutDescriptor } from "stream-chat-react/slot-layout";
// The base app's ChannelsView, unchanged, in a file of its own.
import { ChannelsView } from "./ChannelsView";
import { SIDE_PANEL_SLOT } from "./SidePanel";
import { ThreadsView } from "./ThreadsView";

// The channels layout from the base app, and a threads layout with one slot
// for the picked thread.
const layouts: LayoutDescriptor[] = [
  { id: "channels", slots: ["channel", "thread", SIDE_PANEL_SLOT] },
  { id: "threads", slots: ["thread"] },
];

// Replaces the base app's ChatView, inside Chat and SidePanelProvider.
export const Workspace = () => (
  <ChatView
    layouts={layouts}
    views={{ channels: <ChannelsView />, threads: <ThreadsView /> }}
  >
    <ChatView.Selector />
  </ChatView>
);

ChatView renders only the active view's content, and its children in every view. ChatView.Selector is a column of buttons, Channels and Threads, before the view, so the user can switch from either one. The Threads button shows client.threads.state.unreadThreadCount, the number of threads with unread replies.

The user picks a thread in the list, and it opens beside it:


A thread open beside the list

The Design review thread opened from the threads view, with its row selected in the list

How it works

Info:

Example

The user clicks the Threads button, then the Design review thread:

  1. ChatView.Selector calls setActiveView("threads"). ChatView renders the threads view's content in place of the channels view's. Each view keeps its own slots, so the channel and thread open in the channels view are still there when the user switches back.
  2. ThreadList mounts and calls client.threads.reload({ force: true }). The thread manager queries the user's threads, 25 at a time, and the list renders a ThreadListItem for each, with ThreadRow inside.
  3. Each row subscribes to its thread's state. The server sends the threads with unread replies first, then the rest by their latest reply.
  4. The click calls openThread(thread) from useWorkspaceNavigation(). ChatView binds the thread to the threads view's only slot, thread, and ThreadSlot renders it. isThreadActive(thread.id) is now true for that row, so it's marked selected.
  5. ActiveThread activates the thread while the window is visible and focused. The thread marks itself read, and its row's unread count and the Threads button's count go down.

While the list is on screen, ThreadList keeps client.threads active. A new reply in a listed thread updates its row in place without moving it. A reply in a thread that isn't in the list shows the "unread threads" banner above the rows, and clicking it reloads the list. The user's threads explains how the list loads and stays current.

unreadCount comes from the thread's own read state. A thread counts every reply from someone else as unread until the user reads the thread; Read state in threads lists what changes it.