Customizing ChatView

<ChatView/> works without configuration beyond its views and slots, set up as in Workspace with ChatView. Use this page to change what it does: how navigation behaves, what happens when something opens on top of a panel, going back and forward in a slot, taking a panel off screen, and saving the layout so a reload restores it. It builds on the open options in Beside and on top. The props are in ChatView components and Slot layout.

Changing navigation behaviour

deriveWorkspaceNavigation changes what the navigation of the components inside <ChatView/> does. It receives the SDK's navigation and returns the members to replace. The members it leaves out keep the SDK's behaviour. Here is a deriveWorkspaceNavigation function that opens a channel beside the open one on ⌘/ctrl-click:

import type { DeriveWorkspaceNavigation } from "stream-chat-react/slot-layout";

// Declared outside the component, so ChatView gets the same function on every render.
export const deriveWorkspaceNavigation: DeriveWorkspaceNavigation = (base) => ({
  openChannel: (channel, options) =>
    base.openChannel(channel, {
      ...options,
      additive:
        options?.additive ??
        !!(options?.event?.metaKey || options?.event?.ctrlKey),
    }),
});

Pass it as <ChatView deriveWorkspaceNavigation={deriveWorkspaceNavigation}>. Keep it stable: declare it outside the component, or wrap it in useCallback. A new function on every render rebuilds the navigation on every render. ⌘/ctrl-click opens beside walks through this example.

The override reaches every component that uses useWorkspaceNavigation(), selecting a pinned message and Send direct message in channel details included. Those two pass no event, so this override opens their channel in place of the open one.

Two more props change where things go:

  • viewActionSlotResolvers picks the slot for a channel or a thread, per view. A resolver returns a slot name, or undefined to leave the choice to ChatView. It isn't asked for opens beside, with additive. It may return a slot of the layout that the view doesn't show yet, which adds it. <ChatView/> reads the resolvers after its first render and stores them, so keep them stable: a new object on every render makes it store them again each time.

    import type { ChatViewProps } from "stream-chat-react/slot-layout";
    
    // In the channels view, threads always open in the "thread" slot.
    export const viewActionSlotResolvers: ChatViewProps["viewActionSlotResolvers"] =
      {
        channels: { openThread: () => "thread" },
      };
  • duplicateEntityPolicy says what happens when a channel or thread is opened into a slot while another slot holds it, for example when the user opens the open channel beside itself. With allow, the default, both slots show it. With move, the other slot empties. With reject, nothing opens, including when the channel is already open in the slot it's opened into. resolveDuplicateEntity decides per open instead, and returns one of the three. A channel or thread stacked as a layer isn't checked.

Layers and history

A slot can hold more than one thing over time, in two ways: layers on top of each other, and a history of what it held before.

Layers

A layer is something opened on top of a slot's content. The slot's own content mounts again when the first layer opens and when the last one closes, so it loses its scroll position and the state kept in its components. From the second layer up, everything beneath the top layer stays mounted.

For example, take the side-by-side app from <Slot/> and slotRenderers, with the ⌘/ctrl-click override from Changing navigation behaviour. "Design" is open on the left, and a thread from it on the right. The user ⌘-clicks "Support". It opens on the right, on top of the thread. "Support"'s header now has a back button. Back removes "Support", and the thread shows again. "Support" was the only layer, so the thread mounts again and doesn't keep its scroll position. Close closes the whole right panel: "Support" and the thread beneath it.

Layers come from three places:

  • an open into a slot in use other than the main panel, as described in Where an opened channel or thread lands;
  • the layer: true option;
  • pushLayer(slot, binding) from useChatViewNavigation(), which stacks anything on a slot.

Here is a useOpenProfile hook that opens a member's profile on top of the thread panel:

import { useChatViewNavigation } from "stream-chat-react/slot-layout";

export const useOpenProfile = () => {
  const { pushLayer } = useChatViewNavigation();

  return (userId: string) =>
    pushLayer("thread", {
      key: userId,
      kind: "userProfile",
      source: { userId },
    });
};

The SDK's renderer for userProfile shows only the user's id. Render your own through slotRenderers.userProfile.

close(slot) and popLayer(slot) remove the top layer. Only <Slot/> renders layers; see Rendering a slot.

Back and forward per slot

Each slot remembers what it held before. When an open replaces what a slot holds, the old content goes onto that slot's back list. Going back shows it again and keeps the current content for going forward. Opening something new clears the forward list, and emptying the slot clears both.

The SDK's back button doesn't use this history: it removes a layer. For buttons of your own, call LayoutController.goBack(slot) and LayoutController.goForward(slot) on <ChatView/>'s layout controller. Here is a ChannelHistoryButtons component with a previous and a next button for the channel slot:

import {
  useChatViewContext,
  useLayoutViewState,
} from "stream-chat-react/slot-layout";

export const ChannelHistoryButtons = () => {
  const { layoutController } = useChatViewContext();
  const { slotForwardHistory, slotHistory } = useLayoutViewState();

  return (
    <>
      <button
        disabled={!slotHistory.channel?.length}
        onClick={() => layoutController.goBack("channel")}
        type="button"
      >
        Previous channel
      </button>
      <button
        disabled={!slotForwardHistory.channel?.length}
        onClick={() => layoutController.goForward("channel")}
        type="button"
      >
        Next channel
      </button>
    </>
  );
};

useLayoutViewState() reads the shown view's slots, what they hold, their layers and their history.

Hiding vs closing

useChatViewNavigation() has three ways to take something off screen:

Call What it does
hide(slot) Stops showing the slot. What it holds stays mounted. unhide(slot) shows it again.
close(slot) Removes the top layer. When the slot has no layers, it empties the slot.
release(slot) Empties the slot: what it holds, every layer on it, and its history.

A hidden <Slot/> gets the str-chat__chat-view__slot--hidden class, which hides it, and aria-hidden. <ChannelSlot/> and <ThreadSlot/> don't read the hidden flag. <ThreadSlot/> hides its slot while it holds no thread, unless you pass hideIfEmpty={false}; that matters when a <Slot/> is around it.

The close button in a header uses close in the main panel and release in the panels beside it. The back button uses close.

List slots

A slot that holds a channelList or a threadList is a list slot. Navigation never picks it on its own for what it opens, and close and release never empty it. To take a list off screen, hide its slot.

For example, a one-column layout built on the built-in workspace layout can hide the list while a channel is open. Here is a HideListWhileChannelOpen component that does that:

import { useEffect } from "react";
import {
  useChatViewContext,
  useSlotChannel,
} from "stream-chat-react/slot-layout";

export const HideListWhileChannelOpen = () => {
  const { layoutController } = useChatViewContext();
  const channel = useSlotChannel({ slot: "channel" });

  useEffect(() => {
    if (channel) layoutController.hide("list");
    else layoutController.unhide("list");
  }, [channel, layoutController]);

  return null;
};

It calls the layout controller rather than useChatViewNavigation(). The navigation's functions change with every layout change, so an effect that depends on them would run again after every change it makes.

Saving and restoring a layout

A layout can be saved, for example to localStorage, and restored when the app loads.

Saving

serializeLayoutState(state, options) turns the layout's state into a snapshot: the shown view, and for each view its slots, what they hold, which are hidden, and their history.

What a slot holds is the channel or thread object itself, which can't be stored as JSON. Pass serializeEntityBinding to turn each one into something that can, such as the channel's cid. Return undefined to leave one out.

Restoring

restoreLayoutState(snapshot, options) turns a snapshot back into the layout's state. Its deserializeEntityBinding turns each saved entry back into a channel or thread. Set the result on the layout controller. It replaces the whole layout state, including the shown view and each view's slots, so a snapshot saved under different layouts brings back the old slots.

Here is a LayoutPersistence component that saves the open channels on every change, and restores them when it mounts. Render it inside <ChatView/>:

import { useEffect } from "react";
import type { StreamChat } from "stream-chat";
import { useChatContext } from "stream-chat-react";
import {
  createChatViewSlotBinding,
  getChatViewEntityBinding,
  restoreLayoutState,
  serializeLayoutState,
  useChatViewContext,
} from "stream-chat-react/slot-layout";
import type {
  ChatViewLayoutSnapshot,
  DeserializeLayoutEntityBinding,
  SerializeLayoutEntityBinding,
} from "stream-chat-react/slot-layout";

const STORAGE_KEY = "chat-layout";

// A channel is saved as its cid. Everything else, such as threads, is left out.
const serializeEntityBinding: SerializeLayoutEntityBinding = (binding) => {
  const entity = getChatViewEntityBinding(binding);
  if (entity?.kind !== "channel") return undefined;
  return { key: binding.key, payload: { cid: entity.source.cid } };
};

const createDeserializer =
  (client: StreamChat): DeserializeLayoutEntityBinding =>
  (saved) => {
    const { cid } = saved.payload as { cid: string };
    const [type, id] = cid.split(":");
    const channel = client.channel(type, id);
    // A restored channel isn't loaded yet; watching loads it.
    channel
      .watch()
      .catch((error) => console.error("Could not watch the channel", error));
    return createChatViewSlotBinding({
      key: cid,
      kind: "channel",
      source: channel,
    });
  };

export const LayoutPersistence = () => {
  const { client } = useChatContext();
  const { layoutController } = useChatViewContext();

  useEffect(() => {
    const saved = localStorage.getItem(STORAGE_KEY);
    if (saved) {
      const snapshot = JSON.parse(saved) as ChatViewLayoutSnapshot;
      layoutController.state.next(
        restoreLayoutState(snapshot, {
          deserializeEntityBinding: createDeserializer(client),
        }),
      );
    }

    return layoutController.state.subscribe((state) => {
      const snapshot = serializeLayoutState(state, { serializeEntityBinding });
      localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
    });
  }, [client, layoutController]);

  return null;
};

serializeLayoutControllerState(layoutController, options) and restoreLayoutControllerState(layoutController, snapshot, options) do the same in one call each.

restoreLayoutState calls the deserializer for every saved entry, each slot's back and forward history included, so this one watches every saved channel, not only the ones on screen. Watching is get-or-create: a saved channel that has since been deleted is created again. Saving and restoring the layout watches each channel when it's shown instead, and also keeps the open channel's cid in the URL.

What isn't saved

  • Layers. A reload restores what each slot holds underneath, never something opened on top of it.
  • What your serializer leaves out. Above, threads aren't saved, so a restored channel opens without its thread. In a layout with list slots, save the lists too, or a restored list slot is empty. A list binding holds no data, so it can be saved as it is.
  • Loaded data. A snapshot names channels; it doesn't hold their messages. Each restored channel loads again.

Your own layout controller

<ChatView/> keeps the layout's state in a layout controller, which it creates from layouts, duplicateEntityPolicy and resolveDuplicateEntity. When one of those changes, it creates a new controller, and everything open closes. Pass layoutController to give it a controller of your own instead: <ChatView/> uses it in place of the one it would create, so the layout's state lives as long as your controller does.

The SDK exports the controller's type, LayoutController, but not the class that implements it, so a controller of your own implements that type. To work with the controller <ChatView/> created, read it with useChatViewContext().layoutController, as the examples on this page do.

Used in