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

Pinned messages

Pinning keeps an important message easy to find. This page covers how a message gets pinned, how the channel keeps its list of pinned messages, and the components that show them.

What users get

The user opens the menu on a message and picks Pin. A line above the message now reads "Pinned by" and the user's name, or "Pinned by You" on their own screen. Picking Unpin removes it.

Channel details, from stream-chat-react/channel-detail, have a Pinned messages section. It lists the channel's pinned messages with their author and the date they were sent, and a search field filters them by text. Clicking one jumps the channel's message list to that message.

How it works across the layers

A pin is part of the message. A pinned message has pinned: true, pinned_at, pinned_by and, when the pin expires, pin_expires.

  • State. client.pinMessage and client.unpinMessage change the message on the server. The message.updated event then brings the change to every client watching the channel, and each channel keeps its pinned messages in channel.pinnedMessagesPaginator.
  • UI. MessageUI renders PinIndicator above any message with message.pinned, and adds the class str-chat__message--pinned to it. The Pin action in the message menu pins and unpins. PinnedMessagesView is the Pinned messages section of ChannelDetail.
  • Layout. Selecting a pinned message in channel details calls openChannel from useWorkspaceNavigation(), so with ChatView the channel opens in its slot, and with a WorkspaceNavigationProvider of your own, your openChannel decides. Then it jumps the channel's message list to the message and closes the details.

The pinned list

channel.pinnedMessagesPaginator holds the channel's pinned messages, oldest pin first. It's a paginator like the others in the state layer: its state store has items, isLoading, hasMoreTail, hasMoreHead and lastQueryError. How pagination works explains these fields.

Info:

Example

Take a channel with 25 pinned messages:

  1. When the channel is queried or watched, the response carries a page of its pinned messages. They become the first page of the list.
  2. PinnedMessagePaginator.toTail() loads the next page of earlier pins from the pinned messages endpoint.
  3. Bob pins another message. The message.updated event arrives, the message now has pinned: true, and the list adds it. When he unpins it, the next message.updated removes it.
  4. A pinned message that is edited, reacted to or deleted changes in the list too, because the list and the message list share each message.

Thread replies stay out of the list: the channel only adds messages from its main list. Truncating the channel also clears the pins from before the cutoff.

Pinning and unpinning

Pass the message, or an object with its id:

// Pinned until someone unpins it
await client.pinMessage(message);

// pin_expires takes a Date or an RFC 3339 string: this pin expires in an hour
await client.pinMessage(message, {
  pin_expires: new Date(Date.now() + 60 * 60 * 1000),
});

await client.pinMessage(
  { id: messageId },
  { pin_expires: "2026-12-31T23:00:00Z" },
);

await client.unpinMessage(message);

Neither call changes local state. The message and the pinned list update when message.updated arrives. The Pin action in the UI does more: it updates the message on screen at once and puts it back if the request fails.

Turning it on or off

Pinning is always available. The server decides who may pin through the pin-message capability, which comes from the channel type's permissions. See Permissions reference.

The default Pin action doesn't check that capability: every user sees it on channel messages, and the server rejects the request from a user who can't pin. To hide it from those users, filter the action set by the capability:

import { useMemo } from "react";
import type { ChannelStateData } from "stream-chat";
import {
  MessageActions,
  defaultMessageActionSet,
  useChannel,
  useStateStore,
} from "stream-chat-react";

const capabilitiesSelector = ({ ownCapabilities }: ChannelStateData) => ({
  ownCapabilities,
});

export const MessageActionsWithPinCheck = () => {
  const channel = useChannel();
  const { ownCapabilities } = useStateStore(
    channel.state,
    capabilitiesSelector,
  );
  const canPin = ownCapabilities.includes("pin-message");

  const messageActionSet = useMemo(
    () =>
      canPin
        ? defaultMessageActionSet
        : defaultMessageActionSet.filter(
            (action) =>
              action.placement === "quick-dropdown-toggle" ||
              action.type !== "pin",
          ),
    [canPin],
  );

  return <MessageActions messageActionSet={messageActionSet} />;
};

Register it with <WithComponents overrides={{ MessageActions: MessageActionsWithPinCheck }}>, as Message actions shows.

Customizing

The pin indicator

Replace PinIndicator through WithComponents. It receives the message as a prop:

import type { Channel as ChannelType } from "stream-chat";
import {
  Channel,
  WithComponents,
  type PinIndicatorProps,
} from "stream-chat-react";

const CustomPinIndicator = ({ message }: PinIndicatorProps) => {
  if (!message?.pinned) return null;
  return (
    <div className="custom-pin-indicator">
      📌 {message.pinned_by?.name ?? "Someone"} pinned this
    </div>
  );
};

export const ChannelWithCustomPins = ({
  channel,
}: {
  channel: ChannelType;
}) => (
  <WithComponents overrides={{ PinIndicator: CustomPinIndicator }}>
    <Channel channel={channel}>{/* MessageList, MessageComposer */}</Channel>
  </WithComponents>
);

The Pin indicator recipe has a CSS-only version too.

Your own pinned list

To show pins outside channel details, for example in a bar above the message list, read channel.pinnedMessagesPaginator.state. Place this component inside Channel:

import type { LocalMessage, PaginatorState } from "stream-chat";
import { useChannel, useStateStore } from "stream-chat-react";

const pinnedListSelector = ({
  hasMoreTail,
  isLoading,
  items,
}: PaginatorState<LocalMessage>) => ({ hasMoreTail, isLoading, items });

export const PinnedBar = () => {
  const channel = useChannel();
  const {
    hasMoreTail,
    isLoading,
    items = [],
  } = useStateStore(channel.pinnedMessagesPaginator.state, pinnedListSelector);

  if (!items.length) return null;

  return (
    <div className="pinned-bar">
      {items.map((message) => (
        <button
          key={message.id}
          onClick={() =>
            void channel.messagePaginator.jumpToMessage(message.id)
          }
        >
          {message.user?.name}: {message.text}
        </button>
      ))}
      {hasMoreTail && (
        <button
          disabled={isLoading}
          onClick={() => void channel.pinnedMessagesPaginator.toTail()}
        >
          Earlier pins
        </button>
      )}
    </div>
  );
};

The list keeps itself current, so the bar re-renders when someone pins or unpins a message. Reading state in components explains the selector rules this sample follows. Message list UI covers what the list shows after a jump.

The Pinned messages section

PinnedMessagesView loads its list through a message search, 30 messages per page, filtered to the channel's pinned messages. It runs the search only while channel.pinnedMessagesPaginator holds at least one pin. It takes a searchSource prop to use a search source of your own. To change the sections of channel details, see Channel details and members.

Reference