Preview UI

A channel list renders one row per channel. This recipe gives the row a look of its own. What a row reads and which parts you can replace are explained in Channel list UI.

Note:

What you'll build

  • A compact channel row with avatar, title, time, unread badge and last message
  • A channel chip for showing a channel outside the list

Preview

A channel list with custom rows: avatar, title, time of the last message, unread badge and last message

Approach: Replace one part. Choosing where to start explains the approaches.

Starts from: the base app. The page shows only what the recipe adds.

Best practices

  • Replace ChannelListItemUI, the row's look. ChannelListItem still wraps it: it subscribes to the channel and passes the title, image, latest message, unread count and delivery status as props.
  • Open the channel the way the default row does. Call onSelect when the row gets one, and otherwise openChannel(channel, { event }). Passing the click event keeps ⌘/ctrl-click opens beside working.
  • Keep role="option" and aria-selected on the row's button. The list is a listbox, and its arrow-key navigation moves between options.
  • Give the button an aria-label built with composeChannelListItemAccessibleLabel, as the default row does, and hide the avatar from screen readers. The label reads the channel name, unread count and last message as one sentence.
  • Add the className the row gets to your own. Search results pass str-chat__search-result.
  • Keep the str-chat__channel-list-item-container class on the row's root if you render the row actions. The SDK's styles show the actions while the row is hovered or focused, and the right arrow key moves from the row to them.
  • Preview previewedMessage, not a message you read yourself. A search result passes the matched message there instead of the latest one.
  • Build the last-message line with <SummarizedMessagePreview/> or useLatestMessagePreview. They handle deleted and failed messages, attachments, polls and the sender's name. <SummarizedMessagePreview/> shows the text without inline Markdown. When the line shows the message text, useLatestMessagePreview returns it with its Markdown syntax; see The last-message line.
  • Keep rows light. Every row re-renders when its own channel changes.

Custom channel preview

This row shows the avatar, the title, the time of the last message, an unread badge and the last message. WithCompactChannelRows registers it as ChannelListItemUI for every channel list inside it:

src/CompactChannelRow.tsx
import type { PropsWithChildren } from "react";
import type { ChannelListItemUIProps } from "stream-chat-react";
import {
  ChannelAvatar,
  ChannelListItemActionButtons,
  ChannelListItemTimestamp,
  composeChannelListItemAccessibleLabel,
  SummarizedMessagePreview,
  useChatContext,
  useTranslationContext,
  useWorkspaceNavigation,
  WithComponents,
} from "stream-chat-react";
import "./CompactChannelRow.css";

const CompactChannelRow = ({
  accessibleLabelConfig,
  active,
  channel,
  className,
  displayImage,
  displayTitle,
  groupChannelDisplayInfo,
  messageDeliveryStatus,
  onSelect,
  previewedMessage,
  unread,
}: ChannelListItemUIProps) => {
  const { client, isMessageAIGenerated } = useChatContext();
  const { t, tDateTimeParser, userLanguage } = useTranslationContext();
  const { openChannel } = useWorkspaceNavigation();
  // What a screen reader announces for the row: name, unread count, last message and its time.
  const accessibleLabel = composeChannelListItemAccessibleLabel(
    {
      active,
      channel,
      client,
      displayTitle,
      isMessageAIGenerated,
      latestMessage: previewedMessage,
      messageDeliveryStatus,
      t,
      tDateTimeParser,
      unreadCount: unread,
      userLanguage,
    },
    accessibleLabelConfig,
  );

  return (
    <div className="str-chat__channel-list-item-container">
      <button
        aria-label={accessibleLabel}
        aria-selected={active || undefined}
        // Search results pass their own class, `str-chat__search-result`.
        className={
          className
            ? `cookbook-channel-row ${className}`
            : "cookbook-channel-row"
        }
        onClick={(event) => {
          if (onSelect) onSelect(event);
          else openChannel(channel, { event });
        }}
        role="option"
        type="button"
      >
        <ChannelAvatar
          aria-hidden
          displayMembers={groupChannelDisplayInfo?.members}
          imageUrl={displayImage}
          size="lg"
          userName={displayTitle}
        />
        <div className="cookbook-channel-row__main">
          <div className="cookbook-channel-row__top">
            <span className="cookbook-channel-row__title">{displayTitle}</span>
            <ChannelListItemTimestamp previewedMessage={previewedMessage} />
          </div>
          <SummarizedMessagePreview
            latestMessage={previewedMessage}
            messageDeliveryStatus={messageDeliveryStatus}
            participantCount={channel.data?.member_count}
          />
        </div>
        {!!unread && (
          <span className="cookbook-channel-row__unread">{unread}</span>
        )}
      </button>
      <ChannelListItemActionButtons />
    </div>
  );
};

// Declared once, so WithComponents gets the same object on every render.
const overrides = { ChannelListItemUI: CompactChannelRow };

export const WithCompactChannelRows = ({ children }: PropsWithChildren) => (
  <WithComponents overrides={overrides}>{children}</WithComponents>
);

The rules sit in the stream-overrides layer, so they win over the SDK's own rules once the SDK's stylesheet is in an earlier layer, as Theming → CSS layers sets up. They take their colors, spacing and type from the SDK's tokens, so they follow your theme. The row's root keeps str-chat__channel-list-item-container, so the SDK's styles still show the row actions on hover and focus.

Wrap the part of the app that renders the channel list, for example ChatView, which renders it through ChannelNavigation:

<Chat client={client}>
  <WithCompactChannelRows>
    <ChatView layouts={layouts} views={views} />
  </WithCompactChannelRows>
</Chat>

Using display helpers

Outside a channel list there is no ChannelListItem to compute the props. The hooks it uses are exported, so a channel shown anywhere else can look the same. This chip shows a channel the app holds, for example a "Recent" strip above the list:

src/ChannelChip.tsx
import type { Channel, MessagePaginatorAggregateState } from "stream-chat";
import {
  ChannelAvatar,
  useChannelPreviewInfo,
  useLatestMessagePreview,
  useStateStore,
  useWorkspaceNavigation,
} from "stream-chat-react";
import "./ChannelChip.css";

const latestMessageSelector = ({
  latestMessage,
}: MessagePaginatorAggregateState) => ({
  latestMessage: latestMessage ?? undefined,
});

export const ChannelChip = ({ channel }: { channel: Channel }) => {
  const { openChannel } = useWorkspaceNavigation();
  const { displayImage, displayTitle, groupChannelDisplayInfo } =
    useChannelPreviewInfo({
      channel,
    });
  const { latestMessage } = useStateStore(
    channel.messagePaginator.aggregateState,
    latestMessageSelector,
  );
  const { senderName, text, type } = useLatestMessagePreview({
    latestMessage,
    participantCount: channel.data?.member_count,
  });

  return (
    <button
      className="cookbook-channel-chip"
      onClick={(event) => openChannel(channel, { event })}
      type="button"
    >
      <ChannelAvatar
        aria-hidden
        displayMembers={groupChannelDisplayInfo.members}
        imageUrl={displayImage}
        // Group avatars come in lg, xl and 2xl.
        size="lg"
        userName={displayTitle}
      />
      <span className="cookbook-channel-chip__text">
        <span className="cookbook-channel-chip__title">{displayTitle}</span>
        <span className="cookbook-channel-chip__preview">
          {type === "giphy" ? "🎞️ " : null}
          {senderName ? `${senderName}: ` : null}
          {text}
        </span>
      </span>
    </button>
  );
};

Channel chips under the header

A strip of three channel chips under the channel header, each with the channel avatar, its title and its last message

  • useChannelPreviewInfo({ channel }) returns the title, the image and the group members the default row uses. It subscribes to the channel, so a renamed channel shows its new name.
  • channel.messagePaginator.aggregateState holds the channel's latest message. Read it with useStateStore and a selector declared outside the component; Reading state in components explains the rules.
  • useLatestMessagePreview turns the message into the line's text. type says what the message holds: text, image, giphy, video, voice, file, link, location, poll, deleted, error, empty or unsupported. A Giphy has its own type, so give it its own branch rather than treating it as an image. senderName is "You" for the user's own message, and the author's name in a group. When the line shows the message text, useLatestMessagePreview returns it with its Markdown syntax, so this chip shows syntax such as ** as the user typed it.

Going deeper

When replacing the row's look isn't the right size of change:

  1. Only the last-message line. Override SummarizedMessagePreview and keep the default row around it.
  2. The whole row, wrapper included. Override ListItem. It gets the channel as item, and the default renders ChannelListItem.
  3. The row actions. Override ChannelListItemActionButtons; Channel-list row actions shows how.
  4. The empty and loading states. Override EmptyListIndicator, FirstPageLoadingIndicator and LoadingNextPageIndicator.
  5. When the next page loads. Set loadMoreThresholdPx and loadMoreDebounceMs on ChannelList.

Every key is listed in Channel list UI → Replacing parts, and the props in the Channel Preview reference.

How it works

When the channel list renders a channel:

  1. ChannelList renders a ChannelListItem for the channel. It reads the channel's data, members, read state and latest message, and renders the ChannelListItemUI override, CompactChannelRow, with them as props.
  2. displayTitle and displayImage fall back to the other member's name and image in a direct message, and groupChannelDisplayInfo lets ChannelAvatar show the members of a group without an image.
  3. unread is the user's unread count, or 0 while the channel is muted. active is true while the channel is open in the layout.
  4. SummarizedMessagePreview renders the last-message line. ChannelListItemTimestamp renders its time.
  5. composeChannelListItemAccessibleLabel builds the button's aria-label from the same data, with the parts and order that ChannelListItem's accessibleLabelConfig prop sets, so a screen reader hears the row the way the default row reads it.
  6. ChannelListItemActionButtons renders the row's actions: a mute button, and a menu with archive, block (in a direct message), pin and leave. It reads the row's channel from context, so it needs no props. Leave it out to drop the actions.
  7. A click on the row calls onSelect when the row got one, and otherwise WorkspaceNavigation.openChannel() with the click event. The layout opens the channel, and the row's active turns true.

Channel results in search are ChannelListItems too, so they render with CompactChannelRow as well. They pass onSelect, which opens and watches the channel, and the matched message as previewedMessage.