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.AttachmentUploadPreviewListkeeps laying out the previews, and the other kinds keep their defaults. - Replace
VideoAttachmentUploadPreviewtogether withImageAttachmentUploadPreviewwhen 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
removeAttachmentsandhandleRetry, which every preview receives. - Show the upload state only while
usePendingUploadsEnabled()isfalse, 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:
attachmentis the attachment, with itslocalMetadata:id,file,previewUri(the local file's URI) anduploadState, which ispending,uploading,finished,failedorblocked.removeAttachments(ids)removes attachments from the composer by theirlocalMetadata.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:
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,
},
});![]() | ![]() |
Here is what each part does:
ImageUploadInProgressIndicatorcovers the thumbnail with the upload progress. It reads the upload fromclient.uploadManagerby the attachment'slocalMetadata.id, and renders only while that upload runs andsourceUrlis a local file.ImageUploadRetryIndicatorshows after a failed upload, and callshandleRetry.ImageUploadNotSupportedIndicatormarks 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 rendersFileAttachmentUploadPreview, 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:
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:
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.
Link previews
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:
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.
Related
- To change the whole composer row around the previews, see the Custom composer UI cookbook.
- To compress files before they upload, see the File compression UI cookbook.
- To render an attachment type of your own in the message list, see the Custom attachments UI cookbook.

