Channel details

Channel details is the SDK's panel for one channel: its name and image, its members, its pinned messages, and the photos and files shared in it. The user can mute the channel and leave it there.

Note:

What you'll build

  • The channel's title as a button that opens the details
  • Channel details in the side panel instead of a modal
  • A members view with online members first
  • A restyled card of actions

Preview

The channel details in the side panel beside the Design review channel, opened from the channel's title in the header, with the group's name, avatar, member count and the Mute, Leave and Delete actions

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

Starts from: the base app, with its side panel. The page shows only what the recipe adds: the panel's body, the header whose title opens it, and the stylesheets.

Best practices

  • Start from the SDK's <ChannelDetail/>, from stream-chat-react/channel-detail. It already handles editing, muting, leaving, deleting and the members, and checks the user's capabilities for each action. Channel details and members lists what each section calls.
  • When a modal is enough, pass AvatarWithChannelDetail to <ChannelHeader/> as its Avatar and skip Channel details in the side panel. The avatar then opens the details in a modal.
  • Give <ChannelDetail/> the channel through <ChannelInstanceProvider/>. The side panel sits outside the channel's <Channel/>, and the Channel info section reads the channel from that context for its online status.
  • Make the control that opens the panel a real button, even when it looks like text such as the channel's title. A button takes focus and answers Enter and Space, and needs a name that says what it opens and an aria-expanded that says whether it's open.
  • Pass <ChannelDetail/> an onClose that closes your panel, as Render it in a side panel explains.
  • Change the panel's look through tokens, set on your own element around it. The SDK's rules keep working, and the panel follows your theme.

Channel details in the side panel

Loading the stylesheet

ChannelDetail has its own stylesheet. Put it in the same layer as the SDK's main stylesheet, so the rules in stream-overrides win over both:

src/index.css
@layer stream, stream-overrides;

@import "stream-chat-react/dist/css/index.css" layer(stream);
@import "stream-chat-react/dist/css/channel-detail.css" layer(stream);

The panel's body and its toggle

<ChannelDetails/> renders <ChannelDetail/> for the channel in the channel slot, so it follows the user to another channel. useChannelDetailsPanel() returns toggle, which opens it in the side panel with useSidePanel().openSidePanel() and closes the side panel when the details are the open content, and isOpen, for the control that calls it:

src/ChannelDetailsPanel.tsx
import { useMemo } from "react";
import { ChannelInstanceProvider } from "stream-chat-react";
import { ChannelDetail } from "stream-chat-react/channel-detail";
import type { SectionNavigatorSection } from "stream-chat-react/channel-detail";
import { useSlotChannel } from "stream-chat-react/slot-layout";
import { useSidePanel } from "./SidePanel";
import "./ChannelDetailsPanel.css";

const CHANNEL_DETAILS_ID = "channel-details";

type ChannelDetailsProps = {
  /** The panel's sections, in order. Defaults to the SDK's five. */
  sections?: SectionNavigatorSection[];
};

/**
 * The SDK's `ChannelDetail` for the channel open in the `channel` slot, so it follows the user to
 * another channel.
 */
const ChannelDetails = ({ sections }: ChannelDetailsProps) => {
  const channel = useSlotChannel({ slot: "channel" });
  const { closeSidePanel } = useSidePanel();
  const channelContext = useMemo(
    () => (channel ? { channel } : undefined),
    [channel],
  );

  if (!channelContext) return null;

  return (
    // Channel info reads the channel from the channel context for its online status, and the
    // side panel sits outside the channel's `Channel`.
    <ChannelInstanceProvider value={channelContext}>
      <div className="cookbook-channel-details">
        {/* A new channel starts the panel again on its first section. The sections' close
            button, and actions such as opening a pinned message, close the side panel. */}
        <ChannelDetail
          channel={channelContext.channel}
          key={channelContext.channel.cid}
          onClose={closeSidePanel}
          sections={sections}
        />
      </div>
    </ChannelInstanceProvider>
  );
};

/**
 * Opens the channel's details in the side panel, and closes them when they're the open content.
 * `isOpen` tells the control that toggles them whether the details are on screen.
 */
export const useChannelDetailsPanel = ({
  sections,
}: ChannelDetailsProps = {}) => {
  const { closeSidePanel, openId, openSidePanel } = useSidePanel();
  const isOpen = openId === CHANNEL_DETAILS_ID;

  const toggle = () => {
    if (isOpen) closeSidePanel();
    else
      openSidePanel({
        body: <ChannelDetails sections={sections} />,
        id: CHANNEL_DETAILS_ID,
        // ChannelDetail brings its own header, with the section's title and a close button.
        ownHeader: true,
        title: "Channel details",
      });
  };

  return { isOpen, toggle };
};

In a modal, <ChannelDetail/> is 800 by 640 pixels, and in its narrow layout it takes the whole window. The last rule makes it fill the side panel instead. At that width, <ChannelDetail/> uses its narrow layout: one section at a time, with a menu button in its header that opens the list of sections.

That header has the section's title and its own close button, so useChannelDetailsPanel() opens the details with ownHeader: true: the side panel leaves its title row out. <ChannelDetails/> passes useSidePanel().closeSidePanel to <ChannelDetail/> as onClose, so the section's close button closes the side panel.

Replace the members view

The Members section lists the members with their role and presence. This view shows the online members first, each with an Online badge, and when the others were last active. It reads the members from channel.state.members, and keeps the SDK's member profile: clicking a member opens <ChannelMemberDetail/>, with a back button to the list.


Online members first

The members view: Bob, Cara and the user with an Online badge, and Dan, with when he was last seen and an Offline badge

src/MembersWithPresence.tsx
import { useId, useState } from "react";
import { convertTimestampToDate } from "stream-chat";
import type { ChannelMemberResponse, ChannelStateData } from "stream-chat";
import {
  Avatar,
  Prompt,
  useChatContext,
  useStateStore,
} from "stream-chat-react";
import {
  ChannelMemberDetail,
  defaultChannelDetailSections,
  SectionNavigatorHeader,
  useChannelDetailContext,
} from "stream-chat-react/channel-detail";
import type {
  SectionNavigatorSection,
  SectionNavigatorSectionContentProps,
} from "stream-chat-react/channel-detail";
import "./MembersWithPresence.css";

const membersSelector = ({ members }: ChannelStateData) => ({ members });

// The panel's own text is English, so its dates are too. An app in another language passes its
// language here.
const relativeTime = new Intl.RelativeTimeFormat("en-US", { numeric: "auto" });

// "Last seen 5 minutes ago", in the largest unit that fits.
const formatLastSeen = (lastActive: Date) => {
  const minutes = Math.round((lastActive.getTime() - Date.now()) / 60_000);
  if (Math.abs(minutes) < 60)
    return `Last seen ${relativeTime.format(minutes, "minute")}`;
  const hours = Math.round(minutes / 60);
  if (Math.abs(hours) < 24)
    return `Last seen ${relativeTime.format(hours, "hour")}`;
  return `Last seen ${relativeTime.format(Math.round(hours / 24), "day")}`;
};

const getName = (member: ChannelMemberResponse) =>
  member.user?.name || member.user?.id || member.user_id || "";

type MemberRowProps = {
  isOwnUser: boolean;
  member: ChannelMemberResponse;
  onSelect: (member: ChannelMemberResponse) => void;
};

const MemberRow = ({ isOwnUser, member, onSelect }: MemberRowProps) => {
  const { user } = member;
  const name = getName(member);
  const online = !!user?.online;
  const lastActive = convertTimestampToDate(user?.last_active);
  const hintId = useId();

  // The row's text names the button, status included; the hint adds what clicking it does.
  return (
    <li>
      <button
        aria-describedby={hintId}
        className="cookbook-member-row"
        onClick={() => onSelect(member)}
        type="button"
      >
        <Avatar
          imageUrl={user?.image}
          isOnline={online}
          size="md"
          userName={name}
        />
        <span className="cookbook-member-row__text">
          <span className="cookbook-member-row__name">
            {name}
            {isOwnUser && (
              <span className="cookbook-member-row__you"> (you)</span>
            )}
          </span>
          {!online && lastActive && (
            <span className="cookbook-member-row__last-seen">
              {formatLastSeen(lastActive)}
            </span>
          )}
        </span>
        <span
          className={`cookbook-member-row__status cookbook-member-row__status--${
            online ? "online" : "offline"
          }`}
        >
          {online ? "Online" : "Offline"}
        </span>
        <span hidden id={hintId}>
          Opens {name}&apos;s details
        </span>
      </button>
    </li>
  );
};

/**
 * The Members section with each member's online status: who is online first, then everyone
 * else. It reads the channel's members from `channel.state`, which the client updates when a
 * member comes online or goes offline.
 */
export const MembersWithPresence = ({
  layout,
}: SectionNavigatorSectionContentProps) => {
  const { client } = useChatContext();
  const { channel, close } = useChannelDetailContext();
  const { members } = useStateStore(channel.state, membersSelector);
  const [selectedMember, setSelectedMember] = useState<ChannelMemberResponse>();

  if (selectedMember) {
    return (
      <ChannelMemberDetail
        layout={layout}
        member={selectedMember}
        onBack={() => setSelectedMember(undefined)}
      />
    );
  }

  const sorted = Object.values(members).sort(
    (a, b) =>
      Number(!!b.user?.online) - Number(!!a.user?.online) ||
      getName(a).localeCompare(getName(b)),
  );
  const onlineCount = sorted.filter((member) => member.user?.online).length;

  return (
    <div className="cookbook-members-with-presence">
      <SectionNavigatorHeader
        close={close}
        title={`${sorted.length} members · ${onlineCount} online`}
      />
      <Prompt.Body>
        <ul className="cookbook-members-with-presence__list">
          {sorted.map((member) => (
            <MemberRow
              isOwnUser={member.user?.id === client.userId}
              key={member.user?.id ?? member.user_id}
              member={member}
              onSelect={setSelectedMember}
            />
          ))}
        </ul>
      </Prompt.Body>
    </div>
  );
};

// The default sections, with this view in place of the Members section's content.
export const sectionsWithPresence: SectionNavigatorSection[] =
  defaultChannelDetailSections.map((section) =>
    section.id === "channel-members"
      ? { ...section, SectionContent: MembersWithPresence }
      : section,
  );

<SectionNavigatorHeader/> is the SDK's section header. It shows the menu button in the narrow layout, so the user can still reach the other sections. The view replaces only the section's SectionContent; its id and nav button stay the SDK's.

The list renders every member the channel has loaded, without a search field or paging. For a channel with many members, keep the SDK's view, which searches the members on the server and loads them page by page.

Make the channel's title open the panel

<ChannelHeader/> takes a Title component that replaces only the title. It receives the channel and its display title. The back button, the subtitle, the avatar and the close button stay the SDK's.

The title is a button that calls toggle from useChannelDetailsPanel(), here with the members view above. Leave out sections for the SDK's five sections. It keeps the SDK's str-chat__channel-header__data__title class, so it looks like the title it replaces. The button's name, "Channel details for Design review", says what it opens and includes the visible title, so voice control finds it by what it shows, and aria-expanded tells assistive technology whether the details are open. The stylesheet takes away the button's chrome, underlines the title on hover, and draws the focus ring inside the button, since the header's title column clips anything outside it:

src/ChannelHeaderWithDetails.tsx
import type { ChannelHeaderTitleProps } from "stream-chat-react";
import { ChannelHeader } from "stream-chat-react";
import { useChannelDetailsPanel } from "./ChannelDetailsPanel";
import { sectionsWithPresence } from "./MembersWithPresence";
import "./ChannelHeaderWithDetails.css";

/** The channel's title as a button that opens the channel's details in the side panel, and closes them. */
const TitleThatOpensDetails = ({ displayTitle }: ChannelHeaderTitleProps) => {
  const { isOpen, toggle } = useChannelDetailsPanel({
    sections: sectionsWithPresence,
  });

  return (
    // The name keeps the visible title, so voice control finds the button by what it shows.
    <button
      aria-expanded={isOpen}
      aria-label={`Channel details for ${displayTitle}`}
      className="str-chat__channel-header__data__title cookbook-channel-title-button"
      onClick={toggle}
      type="button"
    >
      {displayTitle}
    </button>
  );
};

export const ChannelHeaderWithDetails = () => (
  <ChannelHeader Title={TitleThatOpensDetails} />
);

Use it in place of <ChannelHeader/> in the base app's channels view:

<ChannelSlot slot="channel">
  <ChannelHeaderWithDetails />
  <MessageList />
  <MessageComposer />
</ChannelSlot>

Restyle it with tokens

The panel's rules read the SDK's tokens, so setting tokens on .cookbook-channel-details, the element around <ChannelDetail/>, restyles it without matching the SDK's selectors. This stylesheet gives the card of actions larger corners through --str-chat__switch-field-border-radius; its background stays the SDK's card surface, which stands out on the side panel. The screenshot at the top of this page shows the result.

src/channel-details-theme.css
@layer stream-overrides {
  /* The card of actions gets larger corners; it keeps the SDK's card background. */
  .cookbook-channel-details {
    --str-chat__switch-field-border-radius: var(--str-chat__radius-xl);
  }
}

How it works

Info:

Example

The user opens Design review and clicks its title, Design review, in the header:

  1. The title calls toggle from useChannelDetailsPanel(), which calls useSidePanel().openSidePanel() with <ChannelDetails/> as the body. The side panel shows it after the channel, and after the thread when one is open, in place of anything it showed.
  2. The panel opens on Channel info: the group's avatar and name, the member and online counts, and the Mute chat switch, Leave chat and Delete chat, which the user's capabilities allow.
  3. The user opens the menu and picks Members. <MembersWithPresence/> reads channel.state.members and lists the online members first, and when the others were last seen.
  4. A member comes online. The client updates their member.user in channel.state.members, and the list moves them up with an Online badge.
  5. The user opens Book club. <ChannelDetails/> reads the new channel from the channel slot and starts again on Channel info for Book club.
  6. The user clicks the close button in the section's header. It calls useSidePanel().closeSidePanel(), the onClose that <ChannelDetails/> passes to <ChannelDetail/>. The side panel closes, and focus goes back to the title. Clicking the title again closes the panel too.

The switch and the buttons in Channel info call the state layer: the switch calls Channel.mute() and Channel.unmute(), and Leave chat asks the user to confirm, then calls Channel.removeMembers() with the user's id. Opening a pinned message also calls close from useChannelDetailContext(), and the channel's message list jumps to it.

The header's online count and the members list can disagree. The header counts channel.state.watcherCount, the users watching the channel now. The list reads member.user.online, which is true while the member is connected to your app anywhere.