Attachments and uploads

Attachments are the files, images and videos a message carries. This page covers how a user adds them in the composer, how they're uploaded, and how the message list shows them.

What users get

The user opens the attachment menu with the + button before the input and picks File. They select two photos and a PDF. Each file shows above the input at once, with its upload progress. They remove one photo with its ✕ and send. In the message list, the photo shows as an image they can open in a gallery, and the PDF as a row with its name, size and a download button.

They can also drop files on the composer or paste them into the input. A file whose upload fails shows a retry button. A file the app's upload settings refuse isn't uploaded: a file row shows "Upload blocked", or "File too large" when it's over the size limit, and an image or video shows a warning icon.

How it works across the layers

  • State. Each composer has an attachment manager, messageComposer.attachmentManager. Its store holds the attachments of the message being written, each upload with an upload state: pending, uploading, finished, failed or blocked. An attachment with nothing to upload, such as a GIF, has none. Sending puts the finished uploads and those attachments into the message.
  • UI. In the composer, AttachmentSelector opens the file picker, AttachmentPreviewList shows the files and their progress, and WithDragAndDropUpload takes dropped files. In the message list, Attachment renders what a message carries.
  • Layout. Not involved.

Adding and uploading a file

Info:

Example

The user adds a PDF. AttachmentManager.uploadFiles() checks it against the app's upload settings and uploads it, to Stream's CDN by default. localMetadata.uploadProgress follows the upload, and its localMetadata.uploadState ends as finished. On send, MessageComposer.send() puts it into the message. By default the message can't be sent while an upload is running; Sending with pending uploads lets it go out at once.

Attachments explains each upload step, the upload states and the notifications. The upload middleware and their ids are on Composer middleware.

In the composer

  • <AttachmentSelector/> opens a menu with File, Poll, Location and Commands. File clicks a hidden file input. When File is the only action offered, the selector is a plain upload button instead of a menu. Attachment Selector lists when each entry is offered. The selector is disabled during a slow-mode cooldown, and hidden while a command is active.
  • <AttachmentPreviewList/> shows each attachment with its progress, a ✕ to remove it and a retry button after a failure. Images and videos open in a gallery. Voice recordings, link previews and a shared location have previews of their own.
  • <WithDragAndDropUpload/> wraps the composer UI and uploads the files dropped on it. Drag and drop explains which files it refuses and when it turns off.
  • Paste. Pasting files into the input uploads them. Pasted text is inserted as text.

Message composer UI shows where each part sits and which store it reads.

In the message list

Attachment sorts a message's attachments into groups and renders each group with its own component. Each component is a prop of Attachment, so you can replace it:

Attachment Rendered by
One image Image
One video Media
Several images or videos ModalGallery
A Giphy Giphy, with AttachmentActions for the preview's buttons
A link preview: has og_scrape_url or title_link Card
A file, an audio file, a voice recording File, Audio, VoiceRecording
A shared location, from message.shared_location Geolocation
Anything else UnsupportedAttachment

The default MessageUI renders Attachment for every message that has attachments or a shared location. A Giphy renders inline and doesn't open the gallery.

Turning it on or off

Attachments are on by default. Two sides decide whether a user can upload:

  • The client: messageComposer.attachments.enabled, true by default.
  • The server: the channel type's uploads setting, set in the Stream Dashboard or through the server API, and the user's upload-file capability.

To turn uploads off in every composer:

client.config.set({
  messageComposer: { attachments: { enabled: false } },
});

With messageComposer.attachments.customCdn: true in client.config, files go to storage Stream doesn't host, so the server's uploads setting and the upload-file capability don't apply, and messageComposer.attachments.enabled decides alone. Configuration lists every setting both sides decide.

attachmentManager.isUploadEnabled says whether the composer accepts a file now. The drop zone and AttachmentManager.uploadFiles() follow it, so a UI of your own should read it too. Uploads lists what it checks. Attachment Selector shows how the File item behaves at the file limit.

Customizing

Limit what users can attach

messageComposer.attachments.acceptedFiles in client.config sets which files the file picker offers and the drop zone takes. messageComposer.attachments.maxNumberOfFilesPerMessage in client.config caps the number per message:

client.config.set({
  messageComposer: {
    attachments: {
      acceptedFiles: ["image/*", "application/pdf"],
      maxNumberOfFilesPerMessage: 5,
    },
  },
});

acceptedFiles doesn't stop a pasted file or one added in code. To refuse a file whatever its source, add a pre-upload middleware. This one blocks videos larger than 50 MB, so the video stays in the composer as blocked, its preview shows the warning icon, and the built-in notification is published:

import type { AttachmentPreUploadMiddleware } from "stream-chat";

const MAX_VIDEO_BYTES = 50 * 1024 * 1024;

const videoSizeLimit: AttachmentPreUploadMiddleware = {
  id: "my-app/video-size-limit",
  handlers: {
    prepare: ({ forward, next, state }) => {
      const { attachment } = state;
      const { file } = attachment.localMetadata;
      if (!file.type.startsWith("video/") || file.size <= MAX_VIDEO_BYTES) {
        return forward();
      }

      return next({
        ...state,
        attachment: {
          ...attachment,
          localMetadata: {
            ...attachment.localMetadata,
            uploadPermissionCheck: {
              reason: "size_limit",
              uploadBlocked: true,
            },
            uploadState: "blocked",
          },
        },
      });
    },
  },
};

client.config.setSetupFunction("messageComposer", ({ composer }) => {
  const { preUploadMiddlewareExecutor } = composer.attachmentManager;

  preUploadMiddlewareExecutor.insert({
    middleware: [videoSizeLimit],
    position: {
      after: "stream-io/attachment-manager-middleware/file-upload-config-check",
    },
    unique: true,
  });

  return () => preUploadMiddlewareExecutor.remove(videoSizeLimit.id);
});

There's one setup function per kind of object, so add this to your app's composer setup function if it has one. Composer middleware explains positions, unique and the teardown.

Upload to your own storage

Register your own upload function as attachments.doUploadRequest, and set customCdn: true when the files never reach Stream. Uploading to your own CDN has the example.

Change the attachment menu

The menu is an action set, defaultAttachmentSelectorActionSet, that you can filter, reorder or extend, for example to leave out Location or add an entry that opens a dialog of your own. Custom action sets shows how. The <AttachmentSelector/> props, such as getModalPortalDestination for where its dialogs open, are in the Attachment Selector reference.

Drop files on a larger area

To take dropped files anywhere in a larger area, such as the whole channel, wrap that area in <WithDragAndDropUpload/>. Drag and drop has the example and explains which composers a drop uploads to.

Change how attachments render

Replace one renderer by passing it to Attachment, and register the result as the Attachment override. Here, attachments of a type of your own, product, get a card, and everything else renders as before. A product attachment reaches UnsupportedAttachment only without og_scrape_url and title_link, because either one makes it a link preview for Card:

import type {
  AttachmentProps,
  UnsupportedAttachmentProps,
} from "stream-chat-react";
import {
  Attachment,
  Channel,
  MessageComposer,
  MessageList,
  UnsupportedAttachment,
  WithComponents,
} from "stream-chat-react";

const ProductOrUnsupported = ({ attachment }: UnsupportedAttachmentProps) => {
  if (attachment.type !== "product") {
    return <UnsupportedAttachment />;
  }

  return (
    <div className="product-card">
      {attachment.image_url && <img alt="" src={attachment.image_url} />}
      <span>{attachment.title}</span>
    </div>
  );
};

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

const overrides = { Attachment: CustomAttachment };

export const ChannelWithProducts = () => (
  <WithComponents overrides={overrides}>
    <Channel channel={channel}>
      <MessageList />
      <MessageComposer />
    </Channel>
  </WithComponents>
);

<Attachment/> also takes imageAttachmentSizeHandler and videoAttachmentSizeHandler, which pick the URL to load for an image or a video at its rendered size, and giphyVersion, the Giphy rendition to load. The CSS variable --str-chat__attachment-max-width sets the largest size of images and videos; it must resolve to pixels. To replace the previews in the composer, see the Attachment previews recipe. Overriding components explains how overrides apply.

Reference