Info:
This is release candidate documentation for Stream Chat React SDK v15. For the latest stable version, see the latest version (v14).

⌘/ctrl-click opens beside

In this recipe, ⌘-click on a Mac, or ctrl-click elsewhere, on a channel in the channel list opens it beside the open channel. A plain click still replaces the open channel. The user can then read two channels side by side.

It starts from the app in Add threads. A channel opened beside goes into the thread slot, next to the open channel.

Overriding navigation

src/App.tsx
import { useEffect } from "react";
import { ChannelPaginator } from "stream-chat";
import type {
  ChannelFilters,
  SortParamRequest,
  Thread as StreamThread,
} from "stream-chat";
import {
  Channel,
  ChannelHeader,
  ChannelNavigation,
  Chat,
  MessageComposer,
  MessageList,
  Thread,
  ThreadHeader,
  ThreadList,
  useCreateChatClient,
} from "stream-chat-react";
import { ChatView, Slot, useActiveThread } from "stream-chat-react/slot-layout";
import type {
  ChatViewSlotRenderers,
  DeriveWorkspaceNavigation,
  LayoutDescriptor,
} from "stream-chat-react/slot-layout";
import "stream-chat-react/dist/css/index.css";
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" }];

const layouts: LayoutDescriptor[] = [
  { id: "channels", slots: ["channel", "thread"] },
  { id: "threads", slots: ["thread"] },
];

// ⌘/ctrl-click on a channel opens it beside the open one. Declared outside the
// component, so ChatView gets the same function on every render.
const deriveWorkspaceNavigation: DeriveWorkspaceNavigation = (base) => ({
  openChannel: (channel, options) =>
    base.openChannel(channel, {
      ...options,
      additive:
        options?.additive ??
        !!(options?.event?.metaKey || options?.event?.ctrlKey),
    }),
});

const ThreadContent = ({ thread }: { thread: StreamThread }) => {
  // Marks the thread as on screen while the window is visible and focused.
  useActiveThread({ activeThread: thread });
  return (
    <Thread thread={thread}>
      <ThreadHeader />
      <MessageList />
      <MessageComposer />
    </Thread>
  );
};

// How a slot shows a channel and a 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 ChannelsView = () => (
  <>
    <ChatView.Selector />
    <ChannelNavigation />
    <Slot slot="channel" />
    <Slot slot="thread" />
  </>
);

const ThreadsView = () => (
  <>
    <ChatView.Selector />
    <ThreadList />
    <Slot slot="thread" />
  </>
);

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
        deriveWorkspaceNavigation={deriveWorkspaceNavigation}
        layouts={layouts}
        slotRenderers={slotRenderers}
        views={{ channels: <ChannelsView />, threads: <ThreadsView /> }}
      />
    </Chat>
  );
};

export default App;
src/layout.css
html,
body,
#root {
  height: 100%;
}

body {
  margin: 0;
}

.str-chat {
  --str-chat__channel-list-width: 320px;
  --str-chat__thread-list-width: 320px;
}

A row in the channel list doesn't decide where its channel opens. It calls openChannel(channel, { event }) and leaves that to the layout. By default, ChatView replaces the open channel, whatever keys are held.

deriveWorkspaceNavigation changes that. ChatView calls it with the SDK's navigation as base, and uses the members it returns in place of the SDK's. This one returns only openChannel, which adds additive: true on ⌘/ctrl-click and hands the call to base. Every other member keeps the SDK's behaviour. The override reaches every component that opens a channel through useWorkspaceNavigation(), so the search results open beside on ⌘/ctrl-click too.

With additive, ChatView skips the slot that already holds a channel and opens the channel in another one. With nothing open yet, a ⌘/ctrl-click opens the channel in the channel slot, like a plain click. With a channel open, here is what the user sees:

  1. With no thread open, the channel opens in the thread slot. Its header has a close button, which closes it.
  2. With a thread open, the channel opens on top of the thread. Its header has a back button, which shows the thread again. The close button closes both.
  3. With a channel open beside, a ⌘/ctrl-clicked channel opens on top of it, so back returns to the one before.
  4. A plain click replaces the channel in the channel slot, and closes a thread open beside it. A channel open beside stays.

Beside and on top and Layers explain both behaviours.

The thread slot now holds a channel or a thread, and sometimes one on top of the other. ChannelSlot shows only a channel and ThreadSlot only a thread, and neither shows what's on top. With the Quick start's ThreadSlot, a channel opened beside wouldn't show at all. Slot shows whatever its slot holds, on top included, through slotRenderers. Each view renders its slots with it. See Slot and slotRenderers.

The SDK's stylesheet gives each slot that holds something an equal share of the width beside the list, so two channels split it in half. A slot with nothing in it gives up its share.

Reading the modifier key

options.event is the click that started the navigation. The channel list and the search results pass it. The SDK doesn't act on it; it's there for an override like this one.

The event is a React mouse, keyboard or touch event. All three have metaKey and ctrlKey, so the override reads them without checking the event's type:

  • metaKey is ⌘ on a Mac.
  • ctrlKey is ctrl on Windows and Linux.

Checking both works on every system. A row in the thread list checks the same two keys.

options?.additive ?? keeps an additive that the caller passed itself. Only when it passed none does the override decide from the keys.

Only clicks that pass their event can open beside through the override:

  • Rows in the channel list and the search results pass it.
  • The replies button, the reply actions on a message, and View next to "Also sent in channel" or "Replied to a thread" don't, so an override can't read ⌘ or ctrl from them.
  • Send direct message on a member's profile and a pinned message in channel details call openChannel without options either, so they open the channel in place of the open one.

Rows in the thread list don't need an override. They open a thread beside the open one on ⌘/ctrl-click when the view has a second thread slot. The threads view here has a single slot, so a ⌘/ctrl-clicked thread replaces the open one.

Keeping the function stable

ChatView builds the navigation from deriveWorkspaceNavigation. Given a new function, it builds the navigation again, and every component that reads it renders again: each row in the channel list, each header. A new function on every render means all of them render every time App does.

The app above declares the override outside the component, so it's the same function on every render. When the override depends on a value inside a component, build it with useCallback, with that value as a dependency.

For example, two channels side by side need a wide window. This hook opens beside only while the window is at least 1200px wide:

src/useOpenBesideOnWideWindows.ts
import { useCallback, useSyncExternalStore } from "react";
import type { DeriveWorkspaceNavigation } from "stream-chat-react/slot-layout";

const wideWindow = window.matchMedia("(min-width: 1200px)");

const subscribeToWidth = (onChange: () => void) => {
  wideWindow.addEventListener("change", onChange);
  return () => wideWindow.removeEventListener("change", onChange);
};

const isWindowWide = () => wideWindow.matches;

export const useOpenBesideOnWideWindows = (): DeriveWorkspaceNavigation => {
  const isWide = useSyncExternalStore(subscribeToWidth, isWindowWide);

  // A new function only when the window crosses 1200px.
  return useCallback<DeriveWorkspaceNavigation>(
    (base) => ({
      openChannel: (channel, options) =>
        base.openChannel(channel, {
          ...options,
          additive:
            options?.additive ??
            (isWide && !!(options?.event?.metaKey || options?.event?.ctrlKey)),
        }),
    }),
    [isWide],
  );
};

In App, call it at the top, before if (!client), and pass the result to ChatView: const deriveWorkspaceNavigation = useOpenBesideOnWideWindows();. The navigation is then built again when the window crosses 1200px, and not on every render.

Always call the base that ChatView passes in. ChatView calls the function again with a new base whenever what's open changes, and an older one still sees the layout as it was.

The members, their options and the types are listed in Workspace navigation API.