Product card

A product card is an attachment type of your own: a product attachment that carries a product's name, price, picture and link. This recipe adds it everywhere an attachment shows up. How the SDK handles attachments in general is on Attachments and uploads.

Note:

What you'll build

  • An Attach product entry with a product picker
  • A product preview above the input
  • A product card in the message list
  • A one-line product summary in the channel list and quoted replies

Preview

The Shop channel with a product card for a glass teapot in the message list, and the channel list row reading "Bob: 🛍 Glass teapot · €34.00"

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

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

The recipe replaces six ComponentContext keys, each wrapping the SDK's default and handing everything that isn't a product back to it. The composer keeps the product in its attachment manager and sends it like any other attachment, also when the message is edited or saved as a draft.

Without it, the SDK doesn't know the type. The message shows "Unsupported attachment", and a reply quoting it shows no content:


Without registering the type

The default rendering: "Unsupported attachment" in Bob's message, and an empty quote in Cleo's reply

Best practices

  • Give the type a name of its own, product, and keep its fields in the attachment's custom. Type them by extending CustomAttachmentData.
  • Don't set title_link or og_scrape_url on the attachment. Either one makes it a link preview, rendered by Card, and the custom renderer never sees it. Keep the product link in custom.
  • Branch on the type in each override and render the SDK's default for everything else, so files, images and link previews keep working.
  • Give an attachment added in code only a localMetadata.id. With no file and no uploadState, the composer sends it as it is.
  • Describe the product in text wherever the SDK shows a message as one line: the channel list and quoted replies.

The attachment type

A product attachment uses the attachment's own fields where they fit, title and image_url, and keeps the rest in custom: the price, its currency and the link to the product page. CustomAttachmentData types custom for every attachment, so the fields are optional. Data conventions explains the custom data interfaces.

The file also has the helpers every part below uses: a check for the type, the price formatted in its currency, a one-line description, and the composer attachment for a product.

src/product.ts
import type { Attachment, LocalAttachment } from "stream-chat";

declare module "stream-chat" {
  // The fields a `product` attachment keeps in its `custom`. Optional, because every attachment
  // in the app shares this interface.
  interface CustomAttachmentData {
    currency?: string;
    price?: number;
    product_url?: string;
  }
}

export const PRODUCT_ATTACHMENT_TYPE = "product";

export type Product = {
  currency: string;
  id: string;
  imageUrl: string;
  price: number;
  title: string;
  url: string;
};

/** True for an attachment this app sends for a product. */
export const isProductAttachment = (attachment: Attachment) =>
  attachment.type === PRODUCT_ATTACHMENT_TYPE;

/** The message's first product attachment, if it has one. */
export const findProductAttachment = (message: {
  attachments?: Attachment[];
}) => message.attachments?.find(isProductAttachment);

/** The price in its currency, for example "€34.00". */
export const formatPrice = ({
  currency,
  price,
}: {
  currency?: string;
  price?: number;
}) =>
  price === undefined || !currency
    ? ""
    : new Intl.NumberFormat(undefined, { currency, style: "currency" }).format(
        price,
      );

/** One line for places that show text only, for example "🛍 Glass teapot · €34.00". */
export const describeProduct = (attachment: Attachment) =>
  [`🛍 ${attachment.title ?? "Product"}`, formatPrice(attachment.custom)]
    .filter(Boolean)
    .join(" · ");

/** The attachment the composer adds for a product. It has nothing to upload, so it needs only an id. */
export const toProductAttachment = (product: Product): LocalAttachment => ({
  custom: {
    currency: product.currency,
    price: product.price,
    product_url: product.url,
  },
  image_url: product.imageUrl,
  localMetadata: { id: crypto.randomUUID() },
  title: product.title,
  type: PRODUCT_ATTACHMENT_TYPE,
});

The picker offers a short, fixed list of products. An app would load them from its shop:

src/catalogue.ts
import type { Product } from "./product";

/** The products the picker offers. An app would load them from its shop. */
export const catalogue: Product[] = [
  {
    currency: "EUR",
    id: "glass-teapot",
    imageUrl: "https://picsum.photos/id/225/480/360",
    price: 34,
    title: "Glass teapot",
    url: "https://example.com/shop/glass-teapot",
  },
  {
    currency: "EUR",
    id: "honey-jar",
    imageUrl: "https://picsum.photos/id/312/480/360",
    price: 9.5,
    title: "Honey jar",
    url: "https://example.com/shop/honey-jar",
  },
  {
    currency: "EUR",
    id: "sprinkled-doughnut",
    imageUrl: "https://picsum.photos/id/488/480/360",
    price: 3.5,
    title: "Sprinkled doughnut",
    url: "https://example.com/shop/sprinkled-doughnut",
  },
];

A product attachment, as the message carries it:

{
  "type": "product",
  "title": "Glass teapot",
  "image_url": "https://picsum.photos/id/225/480/360",
  "custom": {
    "currency": "EUR",
    "price": 34,
    "product_url": "https://example.com/shop/glass-teapot"
  }
}

Adding a product from the composer

The attachment menu is an action set passed to AttachmentSelector. This one keeps the default actions and adds "Attach product" at the end. Its ActionButton opens the action's ModalContent, the picker, through openModalForAction(). Picking a product adds it to the composer with AttachmentManager.upsertAttachments() and closes the modal:

src/ProductPicker.tsx
import type {
  AttachmentSelectorAction,
  AttachmentSelectorActionProps,
  AttachmentSelectorModalContentProps,
} from "stream-chat-react";
import {
  AttachmentSelector,
  ContextMenuButton,
  createIcon,
  defaultAttachmentSelectorActionSet,
  Prompt,
  useContextMenuContext,
  useMessageComposerController,
} from "stream-chat-react";
import { catalogue } from "./catalogue";
import type { Product } from "./product";
import { formatPrice, toProductAttachment } from "./product";
import "./ProductPicker.css";

export const ATTACH_PRODUCT_ACTION = "attachProduct";

const IconBag = createIcon(
  "IconBag",
  <path
    d="M4 7h12l-1 10H5L4 7Zm3 0V6a3 3 0 0 1 6 0v1"
    fill="none"
    stroke="currentColor"
    strokeLinejoin="round"
    strokeWidth="1.5"
  />,
);

/** The menu item: opens the picker in the selector's modal. */
export const AttachProductButton = ({
  openModalForAction,
}: AttachmentSelectorActionProps) => {
  const { closeMenu } = useContextMenuContext();

  return (
    <ContextMenuButton
      className="cookbook-attach-product-button"
      Icon={IconBag}
      onClick={() => {
        openModalForAction(ATTACH_PRODUCT_ACTION);
        closeMenu();
      }}
    >
      Attach product
    </ContextMenuButton>
  );
};

/** The modal's content: picking a product adds it to the composer and closes the modal. */
export const ProductPicker = ({
  close,
}: AttachmentSelectorModalContentProps) => {
  const messageComposer = useMessageComposerController();

  return (
    <Prompt.Root className="cookbook-product-picker">
      <Prompt.Header close={close} title="Attach a product" />
      <Prompt.Body>
        <ul className="cookbook-product-picker__list">
          {catalogue.map((product: Product) => (
            <li key={product.id}>
              <button
                className="cookbook-product-picker__item"
                onClick={() => {
                  messageComposer.attachmentManager.upsertAttachments([
                    toProductAttachment(product),
                  ]);
                  close();
                }}
                type="button"
              >
                <img
                  alt=""
                  className="cookbook-product-picker__image"
                  src={product.imageUrl}
                />
                <span className="cookbook-product-picker__title">
                  {product.title}
                </span>
                <span className="cookbook-product-picker__price">
                  {formatPrice(product)}
                </span>
              </button>
            </li>
          ))}
        </ul>
      </Prompt.Body>
    </Prompt.Root>
  );
};

// Declared once: the selector filters its actions again whenever it gets a new list.
const attachmentSelectorActionSet: AttachmentSelectorAction[] = [
  ...defaultAttachmentSelectorActionSet,
  {
    ActionButton: AttachProductButton,
    ModalContent: ProductPicker,
    type: ATTACH_PRODUCT_ACTION,
  },
];

export const ProductAttachmentSelector = () => (
  <AttachmentSelector
    attachmentSelectorActionSet={attachmentSelectorActionSet}
  />
);

The attachment menu

The attachment menu open above the composer, with File, Location, Commands and Attach product

The product picker

The "Attach a product" dialog listing a glass teapot, a honey jar and a sprinkled doughnut with their prices

createIcon() makes the bag icon, sized like the SDK's own icons. The selector filters the built-in action types, such as uploadFile and createPoll; an action with a type of its own, attachProduct here, is always shown. Attachment Selector lists the action fields and the selector's props.

A product has nothing to upload, so toProductAttachment() gives it a localMetadata.id and no file or uploadState. The composer sends such an attachment as it is, and it doesn't count toward the file limit (messageComposer.attachments.maxNumberOfFilesPerMessage). The localMetadata.id is new for every pick, so the preview can remove it.

Its preview in the composer

The composer's AttachmentPreviewList sends an attachment of a type it doesn't know to its UnsupportedAttachmentPreview. This one renders a product as a small card with the picture, the title and the price, and passes every other attachment to the default:

src/ProductAttachmentPreview.tsx
import type { UnsupportedAttachmentPreviewProps } from "stream-chat-react";
import {
  AttachmentPreviewList,
  RemoveAttachmentPreviewButton,
  UnsupportedAttachmentPreview,
} from "stream-chat-react";
import { formatPrice, isProductAttachment } from "./product";
import "./ProductAttachmentPreview.css";

const ProductOrUnsupportedPreview = (
  props: UnsupportedAttachmentPreviewProps,
) => {
  const { attachment, removeAttachments } = props;

  if (!isProductAttachment(attachment)) {
    return <UnsupportedAttachmentPreview {...props} />;
  }

  return (
    <div className="cookbook-product-preview">
      {attachment.image_url && (
        <img
          alt=""
          className="cookbook-product-preview__image"
          src={attachment.image_url}
        />
      )}
      <div className="cookbook-product-preview__details">
        <span className="cookbook-product-preview__title">
          {attachment.title}
        </span>
        <span className="cookbook-product-preview__price">
          {formatPrice(attachment.custom)}
        </span>
      </div>
      <RemoveAttachmentPreviewButton
        aria-label="Remove product"
        onClick={() => removeAttachments([attachment.localMetadata.id])}
      />
    </div>
  );
};

export const ProductAttachmentPreviewList = () => (
  <AttachmentPreviewList
    UnsupportedAttachmentPreview={ProductOrUnsupportedPreview}
  />
);

A product in the composer

The composer with a honey jar product above the input: its picture, title and price, and a remove button

RemoveAttachmentPreviewButton is the SDK's ✕. It places itself on the top corner of the nearest positioned element, which is why the card is position: relative. Its aria-label names what it removes. The Attachment previews recipe covers the other previews.

Rendering it in the message list

Attachment sorts a message's attachments into groups. A product attachment is none of the kinds it knows, so it goes to its UnsupportedAttachment. This card renders the product with a View link that opens the product page in a new tab:

src/ProductCard.tsx
import type {
  AttachmentProps,
  UnsupportedAttachmentProps,
} from "stream-chat-react";
import { Attachment, UnsupportedAttachment } from "stream-chat-react";
import { formatPrice, isProductAttachment } from "./product";
import "./ProductCard.css";

const ProductOrUnsupported = ({ attachment }: UnsupportedAttachmentProps) => {
  if (!isProductAttachment(attachment)) return <UnsupportedAttachment />;

  const { product_url } = attachment.custom;

  return (
    <div className="cookbook-product-card">
      {attachment.image_url && (
        <img
          alt=""
          className="cookbook-product-card__image"
          src={attachment.image_url}
        />
      )}
      <div className="cookbook-product-card__body">
        <div className="cookbook-product-card__details">
          <span className="cookbook-product-card__title">
            {attachment.title}
          </span>
          <span className="cookbook-product-card__price">
            {formatPrice(attachment.custom)}
          </span>
        </div>
        {product_url && (
          <a
            aria-label={`View ${attachment.title ?? "product"}`}
            className="cookbook-product-card__link"
            href={product_url}
            rel="noreferrer"
            target="_blank"
          >
            View
          </a>
        )}
      </div>
    </div>
  );
};

export const ProductAttachment = (props: AttachmentProps) => (
  <Attachment {...props} UnsupportedAttachment={ProductOrUnsupported} />
);

The product card in a message

Bob's message rendered as a product card: a picture of a glass teapot, its title and price, and a View button

The card fills the attachment's container, which the SDK makes --str-chat__attachment-max-width wide, as it does for files and link previews. The link's visible text is "View" and its accessible name is "View Glass teapot", so a screen reader tells several cards apart. Change how attachments render lists every renderer Attachment takes.

The channel list's last-message preview

The channel list shows a channel's last message in one line, built by useLatestMessagePreview(). For an attachment type it doesn't know, that line is "Unsupported attachment". This preview, set on the SummarizedMessagePreview key, describes the product instead and keeps the sender in front, as the default does:

src/ProductMessagePreview.tsx
import type { SummarizedMessagePreviewProps } from "stream-chat-react";
import {
  SummarizedMessagePreview,
  useLatestMessagePreview,
} from "stream-chat-react";
import { describeProduct, findProductAttachment } from "./product";
import "./ProductMessagePreview.css";

export const ProductMessagePreview = (props: SummarizedMessagePreviewProps) => {
  // The sender prefix the default preview shows: "You", or the sender's name in a group.
  const { senderName } = useLatestMessagePreview(props);
  const product =
    props.latestMessage && findProductAttachment(props.latestMessage);

  if (!product) return <SummarizedMessagePreview {...props} />;

  return (
    <div className="cookbook-product-message-preview">
      {senderName && (
        <span className="cookbook-product-message-preview__sender">
          {senderName}:
        </span>
      )}
      <span className="cookbook-product-message-preview__text">
        {describeProduct(product)}
      </span>
    </div>
  );
};

The channel list row

The Shop channel's row in the channel list, reading "Bob: 🛍 Glass teapot · €34.00"

useLatestMessagePreview() returns the sender's name, "You" for the user's own messages, or nothing in a direct message. The delivery status of the user's own messages isn't shown in this preview. The Preview UI recipe replaces the whole row.

Quoted replies

A reply that quotes a message shows the quoted message above its own text, in the message list and in the composer while the user writes it. QuotedMessagePreviewUI renders both. It shows the quoted message's text, or a label and a thumbnail for the attachment types it knows, such as "Photo"; a product message has neither, so the quote is empty.

These two overrides give the quoted product message a text before passing it to QuotedMessagePreviewUI: QuotedMessage for the quote in the message list, and QuotedMessagePreview for the one above the composer's input:

src/ProductQuotedMessage.tsx
import type {
  LocalMessage,
  MessageComposerState,
  MessageResponse,
} from "stream-chat";
import type {
  QuotedMessagePreviewProps,
  QuotedMessageProps,
} from "stream-chat-react";
import {
  QuotedMessagePreview,
  QuotedMessagePreviewUI,
  useMessageComposerController,
  useMessageContext,
  useMessagePaginator,
  useStateStore,
} from "stream-chat-react";
import { describeProduct, findProductAttachment } from "./product";

/**
 * The quoted preview shows the message's text, or a label for the attachment types it knows. A
 * product message has no text, so it gets one to show.
 */
const withProductText = <T extends LocalMessage | MessageResponse>(
  message: T,
): T => {
  const product = findProductAttachment(message);
  return product && !message.text
    ? { ...message, text: describeProduct(product) }
    : message;
};

/** The quote above a reply in the message list. */
export const ProductQuotedMessage = ({
  renderText: propsRenderText,
}: QuotedMessageProps) => {
  const { message, renderText: contextRenderText } = useMessageContext();
  const messagePaginator = useMessagePaginator();
  const quotedMessage = message.quoted_message;

  if (!quotedMessage) return null;

  return (
    <QuotedMessagePreviewUI
      onClick={(event) => {
        event.preventDefault();
        event.stopPropagation();
        void messagePaginator.jumpToMessage(quotedMessage.id);
      }}
      quotedMessage={withProductText(quotedMessage)}
      renderText={propsRenderText ?? contextRenderText}
    />
  );
};

const quotedMessageSelector = ({ quotedMessage }: MessageComposerState) => ({
  quotedMessage,
});

/** The quote above the composer's input while the user writes a reply. */
export const ProductQuotedMessagePreview = (
  props: QuotedMessagePreviewProps,
) => {
  const messageComposer = useMessageComposerController();
  const { quotedMessage } = useStateStore(
    messageComposer.state,
    quotedMessageSelector,
  );

  if (!quotedMessage || !findProductAttachment(quotedMessage)) {
    return <QuotedMessagePreview {...props} />;
  }

  return (
    // The SDK's slot class keeps the quote placed as the default preview places it.
    <div className="str-chat__message-composer__quoted-message-preview-slot">
      <QuotedMessagePreviewUI
        {...props}
        onRemove={() => messageComposer.setQuotedMessage(null)}
        quotedMessage={withProductText(quotedMessage)}
      />
    </div>
  );
};

A reply quoting the product

Cleo's reply quoting Bob's product message: "Reply to Bob" above "🛍 Glass teapot · €34.00"

Quoting the product in the composer

The composer quoting Bob's product message while Cleo writes a reply

The text is changed only on the copy passed to the preview; the message itself stays as it is. ProductQuotedMessage renders text with the renderText it's given, or the message context's, as the default QuotedMessage does. Clicking the quote in the message list jumps to the quoted message with MessagePaginator.jumpToMessage(), as the default does. In the composer, the ✕ removes the quote with MessageComposer.setQuotedMessage(null). A quoted message that isn't a product goes to the default QuotedMessagePreview.

Putting it together

Register every part under its key, and wrap ChatView, or a single Channel, in WithProductAttachments:

src/WithProductAttachments.tsx
import type { PropsWithChildren } from "react";
import { WithComponents } from "stream-chat-react";
import { ProductAttachmentPreviewList } from "./ProductAttachmentPreview";
import { ProductAttachment } from "./ProductCard";
import { ProductMessagePreview } from "./ProductMessagePreview";
import { ProductAttachmentSelector } from "./ProductPicker";
import {
  ProductQuotedMessage,
  ProductQuotedMessagePreview,
} from "./ProductQuotedMessage";

// Declared once, so WithComponents gets the same object on every render.
const overrides = {
  Attachment: ProductAttachment,
  AttachmentPreviewList: ProductAttachmentPreviewList,
  AttachmentSelector: ProductAttachmentSelector,
  QuotedMessage: ProductQuotedMessage,
  QuotedMessagePreview: ProductQuotedMessagePreview,
  SummarizedMessagePreview: ProductMessagePreview,
};

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

How it works

Info:

Example

The user opens the Shop channel and sends a honey jar:

  1. The user opens the attachment menu with the + button and picks Attach product. AttachmentSelector renders the picker in its modal.
  2. The user picks the honey jar. AttachmentManager.upsertAttachments() adds a product attachment, with only a localMetadata.id, to messageComposer.attachmentManager.
  3. AttachmentPreviewList renders it with ProductOrUnsupportedPreview, since its type is unknown to the list.
  4. The user sends. The composer's attachments middleware adds the finished uploads and the attachments with nothing to upload, such as the product, to the message, without their localMetadata. The server stores the product's custom with it.
  5. The message list renders the message's attachments with Attachment, which sends the product to ProductOrUnsupported.
  6. The channel's last message is now the product message, so its row in the channel list reads "You: 🛍 Honey jar · €9.50".
  7. When Bob replies quoting it, the quote reads "🛍 Honey jar · €9.50".
  8. The user edits the message's text. The composer loads the product back with only a localMetadata.id, and the attachments middleware keeps it in the update.

Every override branches on attachment.type and hands other attachments, and messages without a product, to the SDK's default, so the rest of the app looks as before.