Workspace with ChatView

A layout decides where a channel or a thread opens when the user picks one, and which of them are on screen. <ChatView/>, from stream-chat-react/slot-layout, is the SDK's layout. Use this page to set it up: its views and slots, where an opened channel or thread lands, and how each slot is rendered. The SDK's components ask the layout to open things through Navigation, and Customizing ChatView covers changing that behaviour, layers, history and saving the layout. The props are in ChatView components and Slot layout.

The examples build on the app from Add threads in the Quick start. Here is its layout, a Workspace component:

import {
  ChannelHeader,
  ChannelNavigation,
  MessageComposer,
  MessageList,
  ThreadHeader,
  ThreadList,
} from "stream-chat-react";
import {
  ChannelSlot,
  ChatView,
  ThreadSlot,
} from "stream-chat-react/slot-layout";
import type { LayoutDescriptor } from "stream-chat-react/slot-layout";

// Each view's slots, in order. Declared outside the component, so ChatView gets
// the same array on every render.
const layouts: LayoutDescriptor[] = [
  { id: "channels", slots: ["channel", "thread"] },
  { id: "threads", slots: ["thread"] },
];

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

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

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

export const Workspace = () => (
  <ChatView
    layouts={layouts}
    views={{ channels: <ChannelsView />, threads: <ThreadsView /> }}
  />
);

Views and slots

A view is one screen of the workspace. <ChatView/> has two: channels and threads. One is shown at a time, and <ChatView.Selector/> switches between them.

A slot is a named place in a view. It holds one thing at a time: a channel, a thread or a list. layouts declares each view's slots, in order. Here, the channels view has a channel slot and a thread slot, and the threads view has its own thread slot.

The part of the screen that shows a slot is a panel. The main panel is the first slot that doesn't hold a list; the others sit beside it.

views gives each view the content to render. The content puts the slots where it wants them: <ChannelSlot/> renders the channel in the channel slot, and <ThreadSlot/> the thread in a thread slot. Each view keeps what its slots hold while the other one is shown.

<ChatView/>'s children render in every view, next to the shown view's content. Components that must always run, such as <DisposedChannelRelease/>, go there.

Two things to keep in mind:

  • Declare layouts with views. Without layouts, each view gets a single slot holding its list, and channels and threads have nowhere to open.
  • Keep layouts stable. Given a new array, <ChatView/> starts its layout again, and everything open closes. Declare it outside the component, or memoize it. The same holds for duplicateEntityPolicy and resolveDuplicateEntity, explained in Changing navigation behaviour.

Where an opened channel or thread lands

Info:

Example

The user uses the app above. They click "Design" in the channel list, open the replies to one of its messages, then click "Support":

  1. "Design" opens in the channels view. No slot holds a channel yet, and channel is the first empty slot, so "Design" goes there.
  2. The thread opens in the view that's shown, the channels view. No slot holds a thread, and thread is the first empty slot, so the thread opens beside "Design".
  3. "Support" opens where "Design" is, because that slot holds a channel. "Design" is replaced, and the thread beside it closes.

<ChatView/> follows the same steps for every open.

First, the view. A channel opens in the channels view, switching to it if the threads view is shown. A thread opens in the view that's shown.

Then, the slot. The first of these that applies:

  1. the slot that viewActionSlotResolvers picks, if you passed it (see Changing navigation behaviour);
  2. the slot the call asks for, when that view has it;
  3. the slot that already holds the same kind of thing: a channel for a channel, a thread for a thread. Opening beside, with the additive option, skips this step;
  4. the first empty slot;
  5. the last slot that doesn't hold a list.

When none applies, because every slot holds a list, nothing opens and nothing changes.

Last, replace or stack. In the main panel, the first slot that doesn't hold a list, the new content replaces what the slot holds. Layers already on top of the main panel stay there, over it. In any other slot in use, it's stacked on top as a layer, unless the slot holds the same kind of thing: a thread replaces a thread. A channel opened beside another channel is stacked too, so going back returns to the first one. The layer: true option always stacks.

When a channel opens without being stacked, the threads of the channel it replaces close, even when it's the channel that was already open. Threads of other channels stay. When the slot held no channel before, every thread slot is emptied. Something opened on top of a closed thread stays, in the thread's place.

Only useChatViewNavigation() can ask for a slot. What it puts in a slot shows only if that slot is rendered with <Slot/>: <ChannelSlot/> and <ThreadSlot/> show only their own kind, and no layers, so in the app above this example shows nothing. Its open takes the channel or thread with its kind, and returns whether it opened, replaced something, was layered, or was rejected:

import type { Channel as StreamChannel } from "stream-chat";
import { useChatViewNavigation } from "stream-chat-react/slot-layout";

export const OpenOnTheRightButton = ({
  channel,
}: {
  channel: StreamChannel;
}) => {
  const { open } = useChatViewNavigation();

  return (
    <button
      onClick={() =>
        open(
          { key: channel.cid, kind: "channel", source: channel },
          { slot: "thread" },
        )
      }
      type="button"
    >
      Open on the right
    </button>
  );
};

The key is what <ChatView/> recognizes the channel or thread by: the channel's cid, or the thread's id.

Rendering a slot

A slot holds a channel or a thread; something has to render it. There are three ways.

ChannelSlot and ThreadSlot

<ChannelSlot/> renders a <Channel/> for the channel in its slot, around its children. <ThreadSlot/> does the same with a <Thread/>. While the slot is empty, each renders its fallback, or nothing. This is the way the Quick start uses.

Always pass slot. Without it, they render the first channel or thread in the view, which is only right in a layout with one of each.

<ThreadSlot/> doesn't tell the thread it's on screen. useActiveThread does: while the window is visible and focused, new replies are marked read, and a thread that went out of date, after a lost connection for example, reloads. Call it from a component inside the slot:

import {
  ChannelHeader,
  MessageComposer,
  MessageList,
  ThreadHeader,
  useThreadContext,
} from "stream-chat-react";
import {
  ChannelSlot,
  ChatViewEmptyPlaceholder,
  ThreadSlot,
  useActiveThread,
} from "stream-chat-react/slot-layout";

const ActiveThread = () => {
  const thread = useThreadContext();
  useActiveThread({ activeThread: thread });
  return null;
};

export const ChannelPanel = () => (
  <ChannelSlot fallback={<ChatViewEmptyPlaceholder />} slot="channel">
    <ChannelHeader />
    <MessageList />
    <MessageComposer />
  </ChannelSlot>
);

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

<ChatViewEmptyPlaceholder/> is the SDK's empty state: "No chat selected", or "No thread selected" in the threads view.

<ChannelSlot/> and <ThreadSlot/> show what the slot holds underneath any layer. To show a layer too, use <Slot/>, or read the layer with useSlotTopLayerEntity({ kind, slot }) and render it over the slot.

Slot and slotRenderers

<Slot/> renders whatever its slot holds, and its layers. <ChatView/>'s slotRenderers say how to render each kind of thing. This view has two slots side by side, and each can show a channel or a thread:

import type { Thread as StreamThread } from "stream-chat";
import {
  Channel,
  ChannelHeader,
  ChannelNavigation,
  MessageComposer,
  MessageList,
  Thread,
  ThreadHeader,
} from "stream-chat-react";
import { ChatView, Slot, useActiveThread } from "stream-chat-react/slot-layout";
import type {
  ChatViewSlotRenderers,
  LayoutDescriptor,
} from "stream-chat-react/slot-layout";

const sideBySideLayouts: LayoutDescriptor[] = [
  { id: "channels", slots: ["left", "right"] },
];

const ThreadContent = ({ thread }: { thread: StreamThread }) => {
  useActiveThread({ activeThread: thread });
  return (
    <Thread thread={thread}>
      <ThreadHeader />
      <MessageList />
      <MessageComposer />
    </Thread>
  );
};

// Each renderer returns an element. Hooks go in the component it renders.
const slotRenderers: ChatViewSlotRenderers = {
  channel: ({ source }) => (
    <Channel channel={source}>
      <ChannelHeader />
      <MessageList />
      <MessageComposer />
    </Channel>
  ),
  thread: ({ source }) => <ThreadContent thread={source} />,
};

const SideBySideView = () => (
  <>
    <ChannelNavigation />
    <Slot slot="left" />
    <Slot slot="right" />
  </>
);

export const SideBySideWorkspace = () => (
  <ChatView
    layouts={sideBySideLayouts}
    slotRenderers={slotRenderers}
    views={{ channels: <SideBySideView /> }}
  />
);

A renderer is called as a plain function, not rendered as a component. Calling a hook in it breaks when the slot switches from one kind to another, so it returns an element, here ThreadContent, and the hooks go there. Declare slotRenderers outside the component too.

<Slot/> also takes children, which it renders instead of what the slot holds.

The built-in workspace layout

With layout="nav-rail-entity-list-workspace" and no views, <ChatView/> renders the view itself: the view selector in a rail, then every slot of the shown view, through slotRenderers. A slot can hold a list from the start, through initialBindings. The SDK renders a channelList as <ChannelNavigation/> and a threadList as <ThreadList/>:

import {
  ChatView,
  ChatViewEmptyPlaceholder,
} from "stream-chat-react/slot-layout";
import type { LayoutDescriptor } from "stream-chat-react/slot-layout";
// The slotRenderers from the previous example, in a module of their own.
import { slotRenderers } from "./slotRenderers";

const workspaceLayouts: LayoutDescriptor[] = [
  {
    id: "channels",
    initialBindings: { list: { kind: "channelList", source: {} } },
    slots: ["list", "channel", "thread"],
  },
  {
    id: "threads",
    initialBindings: { list: { kind: "threadList", source: {} } },
    slots: ["list", "thread"],
  },
];

const slotFallbackComponents = { channel: ChatViewEmptyPlaceholder };

export const BuiltInWorkspace = () => (
  <ChatView
    layout="nav-rail-entity-list-workspace"
    layouts={workspaceLayouts}
    slotFallbackComponents={slotFallbackComponents}
    slotRenderers={slotRenderers}
  />
);

An empty slot renders its component from slotFallbackComponents, or <SlotFallback/> for every empty slot. Without either, an empty slot stays blank, and while every slot is blank, the layout shows ChatViewEmptyPlaceholder. These two props apply only to the built-in layout.

The SDK's stylesheet gives every slot an equal share of the width, and a slot with nothing in it gives up its share. Give the list slot a fixed width:

.str-chat__chat-view__slot[data-slot="list"] {
  flex: 0 0 320px;
}

In this layout, <ChatView/> doesn't render its children.

Disposed channels

When a channel is deleted, the user is removed from it, the user logs out, or the channel is released as unused, stream-chat disposes of it: it no longer receives events. A slot that keeps showing it shows a channel that's gone.

<DisposedChannelRelease/> empties the slots of the shown view that hold a disposed channel, so they show their empty state instead. A disposed channel opened on top of a slot, as a layer, stays; the other view's slots are emptied once it's shown. It's opt-in: render it once inside <ChatView/>. Here is the Workspace component from the top of this page with it:

import {
  ChatView,
  DisposedChannelRelease,
} from "stream-chat-react/slot-layout";
// layouts, ChannelsView and ThreadsView as in the Workspace component above.

export const WorkspaceWithCleanup = () => (
  <ChatView
    layouts={layouts}
    views={{ channels: <ChannelsView />, threads: <ThreadsView /> }}
  >
    <DisposedChannelRelease />
  </ChatView>
);

A hidden channel isn't disposed of, so it stays in its slot. When a channel is replaced by another instance of it, <ChatView/> moves the slots of the shown view to the new instance itself, except a layer, and except while the channel's composer still holds a draft. Entity lifecycle explains both.

Used in