Info:
This is release candidate documentation for Stream Chat React Native SDK v10. For the latest stable version, see the latest version (v9).

Attachment previews

The composer shows what a message will carry above the input: each attachment with its upload state, voice recordings and link previews. This UI cookbook replaces those previews, one kind at a time: images and videos as small square thumbnails, the upload progress as a percentage, and voice recordings as a compact row. How attachments are added and uploaded is explained in Attachments and uploads, and where the previews sit in Message composer UI.

Best practices

  • Replace one kind of preview through its own override key, such as ImageAttachmentUploadPreview. AttachmentUploadPreviewList keeps laying out the previews, and the other kinds keep their defaults.
  • Replace VideoAttachmentUploadPreview together with ImageAttachmentUploadPreview when videos should look like images. The default video preview renders the SDK's image preview, not your override.
  • To change only how the upload progress looks, override CircularProgressIndicator. It draws the progress in every preview and on message attachments that are still uploading.
  • Keep the remove and retry buttons working in a preview of your own: call removeAttachments and handleRetry, which every preview receives.
  • Show the upload state only while usePendingUploadsEnabled() is false, as the default previews do. With pending uploads on, the message is sent while its files upload, and the message list shows the progress.
  • To keep the default for some attachments, render the SDK's exported component for them, not the one from useComponentsContext(), which returns your override.
  • Keep previews small. Images, videos and files share one row that scrolls sideways.

Default attachment previews

AttachmentUploadPreviewList renders one preview per attachment in the composer, picked by the attachment's type. Each preview is an override key:

Attachment Override key Default
An image ImageAttachmentUploadPreview A 72 by 72 thumbnail, with the upload progress over it and a remove button in the corner
A video VideoAttachmentUploadPreview The image preview of the video's thumbnail, with the video's duration
Any other file FileAttachmentUploadPreview A row with the file's icon, name, and size or upload progress
An audio file AudioAttachmentUploadPreview A player, when an audio package is installed. Without one, the file preview.
A voice recording AudioAttachmentUploadPreview A player. None with audioRecordingSendOnComplete, which sends the recording at once.
An attachment of a type your app adds none Nothing. The list has no preview for types it doesn't know; see Your own types below.

Voice recordings, and audio files when an audio package is installed, are stacked above the others, each in a row of its own. Images, videos and files share one row that scrolls sideways.

Every preview receives the same three props:

  • attachment is the attachment, with its localMetadata: id, file, previewUri (the local file's URI) and uploadState, which is pending, uploading, finished, failed or blocked.
  • removeAttachments(ids) removes attachments from the composer by their localMetadata.id. Removing one that is still uploading cancels its upload.
  • handleRetry(attachment) uploads a failed attachment again.

Two more previews are rendered outside the list, by MessageInputHeaderView:

  • Link previews, as a card for the first link in the text. They have no override key; see Link previews below.
  • The message being edited or quoted, by Reply.

LocationComposer.setData() puts a location on the message being written, but the composer shows no preview of it. See Location sharing.

Square image and video previews

A replacement renders the whole preview. These show images and videos as 48 by 48 thumbnails. They read the SDK's upload state parts from useComponentsContext(), so overrides of those parts apply here too:

src/SquareMediaPreviews.tsx
import { Pressable, StyleSheet, View } from "react-native";
import type { LocalImageAttachment, LocalVideoAttachment } from "stream-chat";
import {
  useComponentsContext,
  usePendingUploadsEnabled,
  useTheme,
} from "stream-chat-react-native";
import type {
  ImageAttachmentUploadPreviewProps,
  UploadAttachmentPreviewProps,
} from "stream-chat-react-native";

type SquarePreviewProps = UploadAttachmentPreviewProps<
  LocalImageAttachment | LocalVideoAttachment
> & {
  isVideo?: boolean;
  uri: string;
};

const SquarePreview = ({
  attachment,
  handleRetry,
  isVideo,
  removeAttachments,
  uri,
}: SquarePreviewProps) => {
  const {
    icons,
    ImageComponent,
    ImageUploadInProgressIndicator,
    ImageUploadNotSupportedIndicator,
    ImageUploadRetryIndicator,
  } = useComponentsContext();
  const pendingUploadsEnabled = usePendingUploadsEnabled();
  const {
    theme: { semantics },
  } = useTheme();
  const { id, previewUri, uploadState } = attachment.localMetadata;

  return (
    <View style={styles.container}>
      <View
        style={[styles.thumbnail, { borderColor: semantics.borderCoreDefault }]}
      >
        <ImageComponent source={{ uri }} style={StyleSheet.absoluteFill} />
        {isVideo ? (
          <icons.Play
            height={16}
            pathFill={semantics.textOnInverse}
            width={16}
          />
        ) : null}
        {!pendingUploadsEnabled && uploadState === "uploading" ? (
          <ImageUploadInProgressIndicator localId={id} sourceUrl={previewUri} />
        ) : null}
        {!pendingUploadsEnabled && uploadState === "failed" ? (
          <ImageUploadRetryIndicator
            onRetryHandler={() => handleRetry(attachment)}
          />
        ) : null}
        {uploadState === "blocked" ? (
          <ImageUploadNotSupportedIndicator />
        ) : null}
      </View>
      <Pressable
        accessibilityLabel="Remove attachment"
        accessibilityRole="button"
        hitSlop={12}
        onPress={() => removeAttachments([id])}
        style={[
          styles.remove,
          { backgroundColor: semantics.controlRemoveControlBg },
        ]}
      >
        <icons.NewClose
          height={12}
          stroke={semantics.controlRemoveControlIcon}
          width={12}
        />
      </Pressable>
    </View>
  );
};

export const SquareImagePreview = (
  props: ImageAttachmentUploadPreviewProps,
) => {
  const { attachment } = props;
  // The local file, or the uploaded image when the composer was restored from a draft or an edit.
  const uri = attachment.localMetadata.previewUri ?? attachment.image_url;

  return uri ? <SquarePreview {...props} uri={uri} /> : null;
};

export const SquareVideoPreview = (
  props: UploadAttachmentPreviewProps<LocalVideoAttachment>,
) => {
  const { FileAttachmentUploadPreview } = useComponentsContext();
  const { thumb_url } = props.attachment;

  // A video without a thumbnail from its picker shows as a file row.
  return thumb_url ? (
    <SquarePreview {...props} isVideo uri={thumb_url} />
  ) : (
    <FileAttachmentUploadPreview {...props} />
  );
};

const styles = StyleSheet.create({
  container: { padding: 4 },
  remove: {
    borderRadius: 10,
    padding: 2,
    position: "absolute",
    right: 0,
    top: 0,
  },
  thumbnail: {
    alignItems: "center",
    borderRadius: 8,
    borderWidth: 1,
    height: 48,
    justifyContent: "center",
    overflow: "hidden",
    width: 48,
  },
});
A composer with a small square thumbnail and a remove button above the attachment picker on iOS
A composer with a small square thumbnail and a remove button above the attachment picker on Android

Here is what each part does:

  • ImageUploadInProgressIndicator covers the thumbnail with the upload progress. It reads the upload from client.uploadManager by the attachment's localMetadata.id, and renders only while that upload runs and sourceUrl is a local file.
  • ImageUploadRetryIndicator shows after a failed upload, and calls handleRetry. ImageUploadNotSupportedIndicator marks a file the app's upload settings refused. The SDK removes such a file from the composer right after.
  • The video preview uses the thumbnail the picker generated, thumb_url. A video without one renders FileAttachmentUploadPreview, which is your override when you register one.

ImageAttachmentUploadPreviewProps, FileAttachmentUploadPreviewProps and AudioAttachmentUploadPreviewProps are exported. The video preview's props type and its default component aren't, so the video preview is typed with UploadAttachmentPreviewProps<LocalVideoAttachment>, and a video can't fall back to the default video preview. The Composer UI components reference lists the props.

Upload progress

The previews build their upload state from smaller parts, each an override key. From the most specific to the most general:

Override key Shows
CircularProgressIndicator The progress ring, from progress, 0 to 100. Used wherever a progress is known.
MediaUploadProgressOverlay The cover over an image or video thumbnail while it uploads: the ring, or a spinner while the progress is unknown
AttachmentUploadIndicator The overlay over thumbnails, and the ring or a spinner on file rows. Renders only while client.uploadManager has the upload of a local file.
ImageUploadInProgressIndicator, ImageUploadRetryIndicator, ImageUploadNotSupportedIndicator The upload states of the composer's image and video previews
FileUploadInProgressIndicator, FileUploadRetryIndicator, FileUploadNotSupportedIndicator The upload states of the composer's file previews. The in-progress part adds "1.2 MB / 3.0 MB" next to the ring.

The first three also show the progress on message attachments when sending with pending uploads is on, so an attachment looks the same before and after the user sends it. Pending uploads are off by default, and Chat turns them on with enableOfflineSupport. While they're on, the composer's previews show no progress and no retry button.

This ring shows the progress as a percentage:

src/UploadPercentRing.tsx
import { StyleSheet, Text, View } from "react-native";
import type { CircularProgressIndicatorProps } from "stream-chat-react-native";

export const UploadPercentRing = ({
  filledColor,
  progress,
  size = 16,
  style,
  testID,
}: CircularProgressIndicatorProps) => (
  <View
    style={[styles.container, { minHeight: size, minWidth: size }, style]}
    testID={testID}
  >
    <Text style={[styles.percent, { color: filledColor }]}>
      {Math.round(progress)}%
    </Text>
  </View>
);

const styles = StyleSheet.create({
  container: { alignItems: "center", justifyContent: "center" },
  percent: { fontSize: 11, fontVariant: ["tabular-nums"], fontWeight: "600" },
});

size is 16 on file rows and 28 over thumbnails. While the progress is unknown, the SDK shows a spinner instead of the ring. To replace that too, override AttachmentUploadIndicator, as Sending with pending uploads shows.

The audio and voice recording previews render the SDK's own File... indicators, not the ones registered under their keys. A CircularProgressIndicator or AttachmentUploadIndicator override still reaches them.

Compact voice recording previews

A voice recording waiting to be sent shows in AudioAttachmentUploadPreview, the same key as audio files. This preview shows recordings as a row with their duration, and renders the SDK's player for audio files:

src/CompactVoiceRecordingPreview.tsx
import { Pressable, StyleSheet, Text, View } from "react-native";
import { isLocalVoiceRecordingAttachment } from "stream-chat";
import {
  AudioAttachmentUploadPreview,
  useComponentsContext,
  usePendingUploadsEnabled,
  useTheme,
} from "stream-chat-react-native";
import type { AudioAttachmentUploadPreviewProps } from "stream-chat-react-native";

const formatDuration = (seconds = 0) => {
  const total = Math.round(seconds);
  return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`;
};

export const CompactVoiceRecordingPreview = (
  props: AudioAttachmentUploadPreviewProps,
) => {
  const { attachment, handleRetry, removeAttachments } = props;
  const { icons } = useComponentsContext();
  const pendingUploadsEnabled = usePendingUploadsEnabled();
  const {
    theme: { semantics },
  } = useTheme();

  // Audio files keep the SDK's player.
  if (!isLocalVoiceRecordingAttachment(attachment)) {
    return <AudioAttachmentUploadPreview {...props} />;
  }

  const { id, uploadState } = attachment.localMetadata;

  return (
    <View style={[styles.row, { borderColor: semantics.borderCoreDefault }]}>
      <icons.Mic height={16} stroke={semantics.textSecondary} width={16} />
      <Text style={[styles.label, { color: semantics.textPrimary }]}>
        Voice message, {formatDuration(attachment.custom.duration)}
      </Text>
      {!pendingUploadsEnabled && uploadState === "failed" ? (
        <Pressable
          accessibilityRole="button"
          onPress={() => handleRetry(attachment)}
        >
          <Text style={{ color: semantics.textLink }}>Retry</Text>
        </Pressable>
      ) : null}
      <Pressable
        accessibilityLabel="Remove voice message"
        accessibilityRole="button"
        hitSlop={12}
        onPress={() => removeAttachments([id])}
      >
        <icons.NewClose
          height={16}
          stroke={semantics.textSecondary}
          width={16}
        />
      </Pressable>
    </View>
  );
};

const styles = StyleSheet.create({
  label: { flex: 1 },
  row: {
    alignItems: "center",
    borderRadius: 12,
    borderWidth: 1,
    flexDirection: "row",
    gap: 8,
    marginVertical: 4,
    padding: 8,
  },
});

The recording's duration is in seconds, in attachment.custom.duration. The row has no player, so the user can't listen to the recording before sending it. Voice recording is off until Channel and Thread get audioRecordingEnabled. Voice recordings and audio playback covers recording and the players.

The composer's link previews have no override key, and the list that renders them isn't exported. MessageInputHeaderView renders them after the attachment previews, so a replacement of it renders the quoted or edited message and AttachmentUploadPreviewList too. Link previews has a complete header that reads the previews from linkPreviewsManager.state.

Your own types

AttachmentUploadPreviewList renders nothing for an attachment whose type it doesn't know, so an attachment of your own in messageComposer.attachmentManager has no preview. The attachment manager also sends only attachments whose upload has finished. The Custom attachments UI cookbook keeps its product out of the attachment manager: the composer's custom data holds it, a picker above the composer shows it, and a composition middleware adds it to the message when it's sent.

Register the previews

Register the previews and the ring with WithComponents, declared once at module scope:

src/ChannelScreen.tsx
import type { Channel as StreamChannel } from "stream-chat";
import {
  Channel,
  MessageComposer,
  MessageList,
  WithComponents,
} from "stream-chat-react-native";
import type { ComponentOverrides } from "stream-chat-react-native";
import { CompactVoiceRecordingPreview } from "./CompactVoiceRecordingPreview";
import { SquareImagePreview, SquareVideoPreview } from "./SquareMediaPreviews";
import { UploadPercentRing } from "./UploadPercentRing";

const overrides: ComponentOverrides = {
  AudioAttachmentUploadPreview: CompactVoiceRecordingPreview,
  CircularProgressIndicator: UploadPercentRing,
  ImageAttachmentUploadPreview: SquareImagePreview,
  VideoAttachmentUploadPreview: SquareVideoPreview,
};

export const ChannelScreen = ({ channel }: { channel: StreamChannel }) => (
  <WithComponents overrides={overrides}>
    <Channel audioRecordingEnabled channel={channel}>
      <MessageList />
      <MessageComposer />
    </Channel>
  </WithComponents>
);

The list and the previews read the composer from useMessageComposer(), so they work in a channel, in a thread and while a message is being edited. Wrap the thread screen in the same WithComponents, or register the overrides once around your navigator. Overriding components explains how overrides apply.