Channel and thread side by side

This recipe shows the open channel with a thread beside it.

Note:

What you'll build

  • A three-column layout with the thread on the right
  • A "No chat selected" empty state

Preview

The channel list, the open channel, and its thread in a narrower column on the right

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

Starts from: the base app. The page shows the whole app, because the layout replaces the base app's views.

The base app already has a slot for the channel and one for the thread.

A layout with two slots

src/App.tsx
import { useEffect } from "react";
import { ChannelPaginator } from "stream-chat";
import type { ChannelFilters, SortParamRequest } from "stream-chat";
import {
  ChannelHeader,
  ChannelNavigation,
  Chat,
  MessageComposer,
  MessageList,
  ThreadHeader,
  ThreadList,
  useCreateChatClient,
  useThreadContext,
} from "stream-chat-react";
import {
  ChannelSlot,
  ChatView,
  ChatViewEmptyPlaceholder,
  ThreadSlot,
  useActiveThread,
} from "stream-chat-react/slot-layout";
import type {
  ChatViewProps,
  LayoutDescriptor,
} from "stream-chat-react/slot-layout";
import "./layout.css";

const apiKey = "YOUR_API_KEY";
const userId = "YOUR_USER_ID";
const userToken = "YOUR_USER_TOKEN";

const filters: ChannelFilters = {
  members: { $in: [userId] },
  type: "messaging",
};
const sort: SortParamRequest[] = [{ direction: -1, field: "last_message_at" }];

// The channels view has a slot for the open channel and, after it, one for a
// thread opened from it. The threads view has one slot, for the thread picked
// in the list.
const layouts: LayoutDescriptor[] = [
  { id: "channels", slots: ["channel", "thread"] },
  { id: "threads", slots: ["thread"] },
];

// In the channels view, every thread opens in the "thread" slot.
const viewActionSlotResolvers: ChatViewProps["viewActionSlotResolvers"] = {
  channels: { openThread: () => "thread" },
};

// Marks the thread as on screen while the window is visible and focused.
const ActiveThread = () => {
  const thread = useThreadContext();
  useActiveThread({ activeThread: thread });
  return null;
};

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

const ChannelsView = () => (
  <>
    <ChatView.Selector />
    <ChannelNavigation />
    <ChannelSlot fallback={<ChatViewEmptyPlaceholder />} slot="channel">
      <ChannelHeader />
      <MessageList />
      <MessageComposer />
    </ChannelSlot>
    <ThreadPanel />
  </>
);

const ThreadsView = () => (
  <>
    <ChatView.Selector />
    <ThreadList />
    <ThreadPanel />
  </>
);

const App = () => {
  const client = useCreateChatClient({
    apiKey,
    tokenOrProvider: userToken,
    userData: { id: userId },
  });

  useEffect(() => {
    if (!client) return;
    const paginator = new ChannelPaginator({
      client,
      filters,
      id: "my-channels",
      sort,
    });
    client.channelManager.insertPaginator({ paginator });
    return () => {
      client.channelManager.removePaginator(paginator);
    };
  }, [client]);

  if (!client) return <div>Connecting...</div>;

  return (
    <Chat client={client}>
      <ChatView
        layouts={layouts}
        viewActionSlotResolvers={viewActionSlotResolvers}
        views={{ channels: <ChannelsView />, threads: <ThreadsView /> }}
      />
    </Chat>
  );
};

export default App;

layouts gives the channels view two slots, channel and then thread. The view's content places them side by side:

  • ChannelSlot renders the channel in the channel slot. Until the user picks one, it renders its fallback: ChatViewEmptyPlaceholder is the SDK's empty state, "No chat selected".
  • ThreadSlot renders the thread in the thread slot, after the channel. While no thread is open, it renders nothing, so the channel takes the full width.

ThreadPanel is in both views. In the threads view, its thread slot is the view's only slot, and the thread fills the space beside the thread list.

In the CSS, layout.css loads the SDK's stylesheet into the stream layer, and the recipe's rules go in stream-overrides, after it, as Theming → CSS layers explains. The selector .str-chat__chat-view__channels matches only the channels view, so only the thread beside a channel gets the fixed width. The SDK has no variable for that width, so this one rule sets flex on the SDK's thread container. Workspace with ChatView explains views and slots in full.

How it works

Opening a thread beside the channel

The replies button under a message, and the reply actions on it, open its thread.

Info:

Example

The user clicks "3 replies" under Bob's message in Design review:

  1. The button calls openThread with the channel and the message. openThread gets the message's thread from client.threads, which creates it if it doesn't exist yet.
  2. A thread opens in the view that's shown, here the channels view. viewActionSlotResolvers picks its slot: thread.
  3. ThreadSlot renders a Thread for it, around the header, the message list and the composer. The thread loads its replies.
  4. ActiveThread calls useActiveThread. The SDK doesn't tell a thread it's on screen; this hook does, while the window is visible and focused. Then the thread's new replies are marked read, and a thread that went out of date, after a lost connection for example, reloads.

Without viewActionSlotResolvers, ChatView picks the slot by its own rules. While a channel is open, they pick thread too. With no channel open, they would pick the first empty slot, channel, where ChannelSlot doesn't show a thread. The resolver keeps a thread opened from your own code, such as a notification, beside the empty state.

Opening another thread replaces the one beside the channel, because that slot already holds a thread. The other ways to open a thread, and how to add your own button for it, are in Opening a thread.

Closing the thread

Three things close the thread beside the channel:

  • The close button in the thread's header. ThreadHeader renders it on a thread beside a channel. It empties the thread slot. ThreadSlot then renders nothing, and the channel takes the full width.
  • Opening a channel. A click on a channel in the list or in the search results closes the thread, even when it's the channel that's already open.
  • Your own control. useCloseThread() returns the function the close button calls. Back and close shows a custom thread header that uses it.

In the threads view, the thread is the view's main panel, so its header has no close button. The user picks another thread or switches views.

On a phone there's no room for two columns. Below 768px, the SDK's stylesheet also hides the channel list. Single-pane mobile with back button shows one panel at a time, and Responsive layouts lays the thread over the channel instead.