Attachment previews

The composer shows what a message will carry above the input: each attachment with its upload progress, a voice recording, link previews and a location. This recipe replaces those previews, one kind at a time. How attachments are added and uploaded is explained in Attachments and uploads, and where the previews sit in Message composer UI.

Note:

What you'll build

  • Square thumbnails for image attachments
  • Upload progress as a percentage
  • A preview chip for an event attachment of your own
  • A compact voice recording preview

Preview

The composer with image attachments shown as small square thumbnails, one of them still uploading

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

  • Replace one kind of preview through the props of AttachmentPreviewList, and register the wrapped list under the AttachmentPreviewList key. The other kinds keep their defaults.
  • To change only the upload progress or the file sizes, override the small parts first: ProgressIndicator, UploadedSizeIndicator and FileSizeIndicator. They apply to every preview and to message attachments.
  • Keep the ✕ and the retry button working in a preview of your own: call removeAttachments and handleRetry, which every preview receives.
  • Render attachment types of your own in UnsupportedAttachmentPreview, and fall back to the default for the rest.
  • Replace voice recording previews through VoiceRecordingPreviewSlot. They aren't part of AttachmentPreviewList.
  • Keep previews compact, so they don't push the input out of view.

Default attachment preview types

AttachmentPreviewList renders one preview per attachment, picked by the attachment's type. Each component is a prop of the list:

Attachment Prop Default
An image ImageAttachmentPreview A thumbnail with the progress ring over it. Clicking it opens the gallery.
A video VideoAttachmentPreview The same as an image
An audio file AudioAttachmentPreview The same as a file
Any other file FileAttachmentPreview A row with the file's icon, name, progress ring and size
An attachment of a type your app adds UnsupportedAttachmentPreview A row with a generic icon and the ✕

Three previews are rendered outside the list:

  • Voice recordings, by VoiceRecordingPreviewSlot, above the list. It shows the first recording.
  • Link previews, by LinkPreviewList. See Link previews.
  • The location to share, by <GeolocationPreview/>. It has no override key of its own: to show it differently, replace the location item of the composer's preview set, as Custom previews describes.

Every preview receives the attachment and two functions:

  • removeAttachments(ids) removes attachments from the composer. Removing one that is still uploading cancels its upload.
  • handleRetry(attachment) uploads a failed attachment again.

Image and video previews also receive openPreview(), which opens the gallery. The upload state is in attachment.localMetadata: uploadState is pending, uploading, finished, failed or blocked, and uploadProgress goes from 0 to 100.

Customize default attachment previews

Of the defaults in the table, only UnsupportedAttachmentPreview is exported. A replacement for the image, video, audio or file preview renders the whole preview. This one shows images as small square thumbnails:

src/ImageAttachmentPreview.tsx
import { isUploadConfirmationPending } from "stream-chat";
import type { ImageAttachmentPreviewProps } from "stream-chat-react";
import {
  RemoveAttachmentPreviewButton,
  UploadProgressIndicator,
  useComponentContextIcons,
} from "stream-chat-react";

export const ImageAttachmentPreview = ({
  attachment,
  handleRetry,
  openPreview,
  removeAttachments,
}: ImageAttachmentPreviewProps) => {
  const { IconExclamationMark } = useComponentContextIcons();
  const { id, previewUri, uploadPermissionCheck, uploadProgress, uploadState } =
    attachment.localMetadata;
  // The local file until the upload finishes, then the uploaded image.
  const src = attachment.image_url || previewUri;
  // Blocked, or over the size limit: retrying can't help, so the preview only shows the error.
  const cannotUpload =
    uploadState === "blocked" || uploadPermissionCheck?.reason === "size_limit";
  const canRetry = uploadState === "failed" && !cannotUpload;

  return (
    <div className="cookbook-image-preview">
      <button
        aria-label="Open image"
        className="cookbook-image-preview__thumbnail"
        disabled={uploadState !== "finished" || cannotUpload}
        onClick={openPreview}
        type="button"
      >
        {src && <img alt="" src={src} />}
      </button>
      {uploadState === "uploading" && (
        <div className="cookbook-image-preview__progress">
          <UploadProgressIndicator
            // True while the file is sent but the server hasn't confirmed it: a spinner, not 100%.
            uploadConfirmationPending={isUploadConfirmationPending(
              attachment.localMetadata,
            )}
            uploadProgress={uploadProgress}
          />
        </div>
      )}
      {cannotUpload && (
        <div
          aria-label="Can't upload"
          className="cookbook-image-preview__error"
          role="img"
        >
          <IconExclamationMark />
        </div>
      )}
      {canRetry && (
        <button
          className="cookbook-image-preview__retry"
          onClick={() => handleRetry(attachment)}
          type="button"
        >
          Retry
        </button>
      )}
      <RemoveAttachmentPreviewButton
        onClick={() => removeAttachments([id])}
        uploadState={uploadState}
      />
    </div>
  );
};

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.

RemoveAttachmentPreviewButton is the SDK's ✕. It places itself on the top corner of the nearest positioned element, which is why the preview's root is position: relative. Its label says "Cancel upload" while the file is uploading, and "Remove attachment" after. UploadProgressIndicator shows the progress ring, or a spinner while the progress is unknown or the file is sent but uploadConfirmationPending says the server hasn't confirmed it yet; isUploadConfirmationPending(attachment.localMetadata) from stream-chat works that out, as the SDK's own previews do.

A file can also be refused before it uploads: uploadState is blocked, or localMetadata.uploadPermissionCheck.reason is size_limit for a file over the app's size limit. Retrying can't help either, so the preview shows the SDK's error mark over the thumbnail, keeps the gallery closed and offers only the ✕. A failed upload shows the retry button.

Pass the preview to AttachmentPreviewList, and register the list under the AttachmentPreviewList key:

src/WithSquareImagePreviews.tsx
import type { PropsWithChildren } from "react";
import { AttachmentPreviewList, WithComponents } from "stream-chat-react";
import { ImageAttachmentPreview } from "./ImageAttachmentPreview";
import "./ImageAttachmentPreview.css";

const CustomAttachmentPreviewList = () => (
  <AttachmentPreviewList ImageAttachmentPreview={ImageAttachmentPreview} />
);

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

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

Wrap ChatView, or a single Channel, in WithSquareImagePreviews.

The props types of the image, file and voice recording previews are exported: ImageAttachmentPreviewProps, FileAttachmentPreviewProps and VoiceRecordingPreviewProps. Those of the video and audio previews aren't.

Upload progress and size UI

The default previews build their upload UI from small parts, each with an override key:

Override key Shows Receives
ProgressIndicator The progress ring while a file uploads percent, from 0 to 100
UploadedSizeIndicator "1.2 MB / 3 MB" on a file row while it uploads uploadedBytes, fullBytes
FileSizeIndicator A file's size, on a file row once it's uploaded and on file attachments in messages fileSize

While the progress is unknown, or every byte is sent and the server hasn't answered yet, the previews show LoadingIndicator instead of the ring. That key also replaces the SDK's other spinners.

The same parts show the progress on message attachments when sending with pending uploads is turned on, so an attachment looks the same before and after the user presses send. To replace that whole block on message attachments, the ring and the sizes together, override AttachmentUploadProgressIndicator. It receives the attachment, or a gallery's attachments.

This override shows the progress as a percentage:

src/UploadPercent.tsx
import type { PropsWithChildren } from "react";
import type { ProgressIndicatorProps } from "stream-chat-react";
import { WithComponents } from "stream-chat-react";
import "./UploadPercent.css";

const UploadPercent = ({ percent }: ProgressIndicatorProps) => (
  <span className="cookbook-upload-percent">{Math.round(percent)}%</span>
);

const overrides = { ProgressIndicator: UploadPercent };

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

Upload progress as a percentage

The default image previews with the upload progress shown as 60% on the one still uploading

Customize custom attachment previews

An attachment your app adds to the composer, for example with AttachmentManager.upsertAttachments(), has a type the list doesn't know. It goes to UnsupportedAttachmentPreview. Branch on your own type there, and render the default for everything else:

src/CustomAttachmentPreviewList.tsx
import type { PropsWithChildren } from "react";
import type { UnsupportedAttachmentPreviewProps } from "stream-chat-react";
import {
  AttachmentPreviewList,
  UnsupportedAttachmentPreview,
  useComponentContextIcons,
  WithComponents,
} from "stream-chat-react";
import "./CustomAttachmentPreviewList.css";

const EventOrUnsupportedPreview = (
  props: UnsupportedAttachmentPreviewProps,
) => {
  const { IconXmarkSmall } = useComponentContextIcons();
  const { attachment, removeAttachments } = props;

  if (attachment.type !== "event") {
    return <UnsupportedAttachmentPreview {...props} />;
  }

  return (
    <div className="cookbook-event-preview">
      <span className="cookbook-event-preview__title">
        📅 {attachment.title}
      </span>
      <button
        aria-label="Remove event"
        className="cookbook-event-preview__remove"
        onClick={() => removeAttachments([attachment.localMetadata.id])}
        type="button"
      >
        <IconXmarkSmall />
      </button>
    </div>
  );
};

export const CustomAttachmentPreviewList = () => (
  <AttachmentPreviewList
    UnsupportedAttachmentPreview={EventOrUnsupportedPreview}
  />
);

const overrides = { AttachmentPreviewList: CustomAttachmentPreviewList };

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

An event attachment chip

An event attachment shown as a chip with a calendar emoji, its title and a remove button

WithEventPreviews registers the list under the AttachmentPreviewList key, as in the image example. An attachment added with AttachmentManager.upsertAttachments() needs a unique localMetadata.id: the preview removes it by that id. To render the same attachment in the message list, see Change how attachments render.

Customize voice recording previews

A voice recording waiting to be sent shows in VoiceRecordingPreviewSlot, above the other previews. Give it your own VoiceRecordingPreview:

src/VoiceRecordingPreview.tsx
import type { PropsWithChildren } from "react";
import type { VoiceRecordingPreviewProps } from "stream-chat-react";
import {
  RemoveAttachmentPreviewButton,
  VoiceRecordingPreviewSlot,
  WithComponents,
} from "stream-chat-react";
import "./VoiceRecordingPreview.css";

const CompactVoiceRecordingPreview = ({
  attachment,
  removeAttachments,
}: VoiceRecordingPreviewProps) => {
  const { id, previewUri, uploadState } = attachment.localMetadata;

  return (
    <div className="cookbook-voice-preview">
      <audio controls src={attachment.asset_url || previewUri} />
      <RemoveAttachmentPreviewButton
        onClick={() => removeAttachments([id])}
        uploadState={uploadState}
      />
    </div>
  );
};

const CustomVoiceRecordingPreviewSlot = () => (
  <VoiceRecordingPreviewSlot
    VoiceRecordingPreview={CompactVoiceRecordingPreview}
  />
);

const overrides = {
  VoiceRecordingPreviewSlot: CustomVoiceRecordingPreviewSlot,
};

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

A voice recording preview

A voice recording waiting to be sent, shown as the browser's audio player with a remove button

Wrap ChatView in WithVoicePreview. Voice recording is off until MessageComposer gets audioRecordingEnabled: <MessageComposer audioRecordingEnabled />. Voice recordings and audio playback covers recording and the players.

How it works

Info:

Example

The user adds two photos to the composer in Weekend trip, with WithSquareImagePreviews around ChatView:

  1. The composer adds an attachment for each photo. AttachmentPreviewList renders one preview per attachment, and picks ImageAttachmentPreview for images.
  2. While a photo uploads, attachment.localMetadata.uploadState is uploading. The thumbnail shows the local file, previewUri, and UploadProgressIndicator shows the progress ring over it.
  3. When the upload finishes, uploadState is finished and the thumbnail shows attachment.image_url. Clicking it calls openPreview(), which opens the gallery.
  4. When an upload fails, uploadState is failed, and Retry calls handleRetry(attachment). The ✕ calls removeAttachments([id]); on a photo that is still uploading, it cancels the upload.

Files, videos, voice recordings and link previews keep their default previews.