Pin indicator

A pinned message shows a "Pinned by" line above its bubble. This recipe changes how that line looks and what it shows.

Note:

What you'll build

  • A "Pinned by" line restyled as a chip
  • Who pinned the message and on which day
  • When the pin expires
  • An Unpin button

Preview

The custom indicator: who pinned the message and when, and an Unpin button

Approach: Restyle and 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

  • Restyle the default indicator with CSS when only its look changes. A pinned message gets the class str-chat__message--pinned, and the indicator renders str-chat__message-pin-indicator.
  • Replace PinIndicator when the indicator needs more data or an action. It renders inside the message, so it can read useMessageContext().
  • Keep the str-chat__message-pin-indicator class on your component's root. The message's grid uses it to place the indicator above the bubble.
  • Offer Unpin only to users with the pin-message capability, as the default Pin action does. The server rejects the request from a user without it.
  • Keep the indicator to one short line, so it doesn't push the message content around.

Custom pin indicator

CSS based solution

By default, a pinned message shows the "Pinned by" line above its bubble, and its row in the list is highlighted:


The default pin indicator

The default pin indicator above a pinned message

The default indicator is an icon and a label inside str-chat__message-pin-indicator__content. These rules turn it into a chip with a border and an icon in the accent color:

src/pin-indicator.css
@layer stream-overrides {
  .str-chat__message--pinned
    .str-chat__message-pin-indicator
    .str-chat__message-pin-indicator__content {
    width: fit-content;
    padding: var(--str-chat__spacing-xxs) var(--str-chat__spacing-xs);
    border: 1px solid var(--str-chat__accent-primary);
    border-radius: var(--str-chat__radius-sm);
    background-color: var(--str-chat__background-core-surface-subtle);
  }

  .str-chat__message--pinned
    .str-chat__message-pin-indicator__content
    .str-chat__message-pin-indicator__icon {
    color: var(--str-chat__accent-primary);
  }
}

The indicator as a chip

The pin indicator restyled as a chip

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 icon takes the text color, so color on the icon colors it. The highlighted row comes from the list item around a pinned message, which paints --str-chat__background-core-highlight behind it.

Component based solution

CSS can't add information. This indicator says who pinned the message and on which day, shows when the pin expires, and has an Unpin button:

src/PinnedByIndicator.tsx
import type { PropsWithChildren } from "react";
import { convertTimestampToDate } from "stream-chat";
import {
  useChatContext,
  useMessageContext,
  useMessagePermissions,
  WithComponents,
} from "stream-chat-react";
import "./PinnedByIndicator.css";

const PinnedByIndicator = () => {
  const { client } = useChatContext();
  const { handlePin, message } = useMessageContext();
  const { canPin } = useMessagePermissions(message);

  if (!message.pinned) return null;

  const pinnedBy = message.pinned_by;
  const pinnedByName =
    pinnedBy?.id === client.user?.id
      ? "you"
      : (pinnedBy?.name ?? pinnedBy?.id ?? "someone");
  const pinnedAt = convertTimestampToDate(message.pinned_at);
  const expiresAt = convertTimestampToDate(message.pin_expires);

  return (
    <div className="str-chat__message-pin-indicator cookbook-pinned-by">
      <span>
        <span aria-hidden>📌</span> Pinned by {pinnedByName}
        {pinnedAt && ` on ${pinnedAt.toLocaleDateString()}`}
      </span>
      {expiresAt && (
        <span className="cookbook-pinned-by__expiry">
          until {expiresAt.toLocaleString()}
        </span>
      )}
      {canPin && (
        <button
          className="cookbook-pinned-by__unpin"
          onClick={handlePin}
          type="button"
        >
          Unpin
        </button>
      )}
    </div>
  );
};

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

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

The screenshot at the top of this page shows the result.

Wrap the channel in WithPinnedByIndicator:

src/PinnedChannel.tsx
import type { Channel as StreamChannel } from "stream-chat";
import { Channel, MessageComposer, MessageList } from "stream-chat-react";
import { WithPinnedByIndicator } from "./PinnedByIndicator";

export const PinnedChannel = ({ channel }: { channel: StreamChannel }) => (
  <WithPinnedByIndicator>
    <Channel channel={channel}>
      <MessageList />
      <MessageComposer />
    </Channel>
  </WithPinnedByIndicator>
);

How it works

  1. The default MessageUI renders the PinIndicator key above the bubble of every message with message.pinned, and passes the message as message. PinnedByIndicator reads it from MessageContext instead, together with handlePin.
  2. pinned_by, pinned_at and pin_expires are on the message. The timestamps are nanosecond numbers, so convertTimestampToDate turns them into dates; Data conventions explains why.
  3. useMessagePermissions(message).canPin is the user's pin-message capability in the channel. Without it, the button isn't rendered.
  4. Clicking Unpin calls handlePin, which unpins a pinned message. The indicator disappears at once, and comes back if the request fails.

The indicator only renders where the default MessageUI does. A custom message UI renders whatever it wants for a pinned message.

  • Pinned messages explains how pinning works and the list of a channel's pinned messages. Turning it on or off there explains who may pin.
  • Actions replaces the Pin action with one that asks for confirmation.
  • Permissions reference lists the pin-message capability and the roles that have it.
  • Theming puts the SDK's stylesheet in a layer, so the stream-overrides rules win.
  • MessageContext lists handlePin and the message's other handlers.
  • Custom message replaces the message UI that renders the indicator.