Pinned-messages bar

This recipe adds a bar under the channel header that shows the channel's pinned messages and jumps to them.

Note:

What you'll build

  • A bar under the channel header with the latest pin
  • Jumping to the pinned message on click
  • Stepping back through older pins, click by click
  • A bar that follows new pins and unpins

Preview

A bar under the channel header reading "Pinned message #3" and the text of the most recent pin

Approach: Compose. Choosing where to start explains the approaches.

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

Best practices

  • Read the pins from channel.pinnedMessagesPaginator.state. The paginator keeps itself current, so the bar changes when someone pins or unpins a message, without a request of its own.
  • Jump with MessagePaginator.jumpToMessage() on channel.messagePaginator. It loads the messages around a pin that isn't loaded yet, and the message list scrolls to it and highlights it.
  • Keep the shown pin by its id, not its position. A new pin then doesn't shift the bar to another message.
  • Keep the bar to one line of text, so it doesn't take space from the message list.

Pinned-messages bar

1. The bar

PinnedMessagesBar reads the loaded pins and shows one of them. Until the first click, that's the most recent pin. A click jumps the message list to the shown pin and then shows the one pinned before it. After the oldest, it shows the most recent again. The title numbers the pins from the oldest, as channel.pinnedMessagesPaginator.state.items orders them:

src/PinnedMessagesBar.tsx
import { useState } from "react";
import type { LocalMessage, PaginatorState } from "stream-chat";
import { useChannel, useStateStore } from "stream-chat-react";
import "./PinnedMessagesBar.css";

const pinnedItemsSelector = ({ items }: PaginatorState<LocalMessage>) => ({
  items,
});

export const PinnedMessagesBar = () => {
  const channel = useChannel();
  const { items = [] } = useStateStore(
    channel.pinnedMessagesPaginator.state,
    pinnedItemsSelector,
  );
  // The pin the bar shows. Until the first click, and when that pin is gone, the most recent one.
  const [shownId, setShownId] = useState<string>();

  if (!items.length) return null;

  // `items` holds the oldest pin first, so the most recent pin is the last item.
  const shownIndex = items.findIndex((message) => message.id === shownId);
  const index = shownIndex === -1 ? items.length - 1 : shownIndex;
  const shown = items[index];

  const jumpToShownPin = () => {
    void channel.messagePaginator.jumpToMessage(shown.id);
    // Next, the pin before it; after the oldest, the most recent again.
    setShownId((items[index - 1] ?? items[items.length - 1]).id);
  };

  return (
    <button
      className="cookbook-pinned-bar"
      onClick={jumpToShownPin}
      type="button"
    >
      <span className="cookbook-pinned-bar__content">
        <span className="cookbook-pinned-bar__title">
          Pinned message{items.length > 1 && ` #${index + 1}`}
        </span>
        <span className="cookbook-pinned-bar__text">
          {shown.user?.name ?? shown.user?.id}: {shown.text}
        </span>
      </span>
    </button>
  );
};

The selector is declared at module scope and picks only items, so the bar re-renders when the pins change and not on every paginator update. Selector identity explains why.

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.

2. Put it under the header

useChannel() reads the channel from the Channel around the bar, so the bar renders inside it. Render it right after ChannelHeader:

src/ChannelHeaderWithPinnedBar.tsx
import { ChannelHeader } from "stream-chat-react";
import { PinnedMessagesBar } from "./PinnedMessagesBar";

// Rendered where the channel header goes, inside the channel, so the bar sits right under it.
export const ChannelHeaderWithPinnedBar = () => (
  <>
    <ChannelHeader />
    <PinnedMessagesBar />
  </>
);

With ChatView, render ChannelHeaderWithPinnedBar in place of ChannelHeader among the children of ChannelSlot: the slot renders them inside the Channel it opens. Without it, put it inside your Channel, above MessageList.

After a click, the list shows the pin the bar showed, highlighted, and the bar shows the pin before it:


After a click on the bar

The message list scrolled to the pinned message about the van, highlighted, and the bar now reading "Pinned message #2" with the packing list

How it works

Info:

Example

A channel has three pinned messages, and the user opens it:

  1. The channel's watch response carries its pins, and they become the first page of channel.pinnedMessagesPaginator. channel.pinnedMessagesPaginator.state.items holds them, oldest pin first. The bar shows the last one, "Pinned message #3".
  2. The user clicks the bar. MessagePaginator.jumpToMessage() shows the messages around that pin, loading them if they aren't loaded, and publishes a focus signal in channel.messagePaginator.messageFocusSignal.
  3. MessageList reads the signal, scrolls the message to the middle and highlights it with the str-chat__message--highlighted class for three seconds after it's on screen. The scroll-to-latest button shows while the list isn't at the latest message.
  4. The bar now shows "Pinned message #2". Two more clicks jump to #2 and then #1, and the bar goes back to #3.
  5. Someone unpins #2. The message.updated event removes it from channel.pinnedMessagesPaginator.state.items, and the bar, which kept #2 by its id, shows the most recent pin again.

The bar goes through the loaded pins only. The watch response may carry only some of a channel's pins, and channel.pinnedMessagesPaginator.state.hasMoreTail can be false even when the channel has more. For a channel with many pins, call PinnedMessagePaginator.reload() to load a full page, and PinnedMessagePaginator.toTail() to load earlier pins. Pinned messages paginator explains both.

The bar shows a pin, not where pins are in the list. To mark the pinned messages themselves, see the Pin indicator recipe.