File compression
Stream accepts uploads up to 100 MB unless your app sets a lower limit, and a phone's photos and videos are often far larger than a chat needs. This UI cookbook makes uploads smaller before they leave the device: it compresses photos, scales down large ones, and compresses videos, and then sends them to Stream's CDN or to your own storage. How attachments are picked and uploaded is explained in Attachments and uploads.
There are three places to step in:
| What you change | Where | Applies to |
|---|---|---|
| The JPEG quality of images | compressImageQuality on Channel and Thread |
Images from the photo grid, the system image picker and the camera |
| How an image is compressed | The compressImage native handler |
The same images, when compressImageQuality is set |
| The upload of every file | messageComposer.attachments.doUploadRequest in client.config or the composer setup function |
Every file, video and voice recording the composer uploads |
Best practices
- Compress only what needs it. Re-encoding a small image or video costs time and quality, and saves little.
- Keep the file's
nameandtype. Other clients pick how to render an attachment from them. - Fall back to the original file when compression fails, so the upload still goes through.
- Wrap the default
compressImagehandler fromdefaultNativeHandlersrather than writing one from scratch. - Register upload functions for all composers, through
client.configor the composer setup function. Every upload in a channel goes through the channel composer's upload function, including the uploads of a thread or of a message being edited.
Compress images
compressImageQuality, from 0 to 1, re-encodes each picked image as a JPEG of that quality before it's uploaded. Set it on Channel and on Thread, which take it separately:
import type { Channel as StreamChannel } from "stream-chat";
import {
Channel,
MessageComposer,
MessageList,
} from "stream-chat-react-native";
export const ChannelScreen = ({ channel }: { channel: StreamChannel }) => (
<Channel channel={channel} compressImageQuality={0.7}>
<MessageList />
<MessageComposer />
</Channel>
);The SDK compresses an image only when the picker reported its width and height, and only when the quality is a number other than 1. Without the prop, images from the photo grid and the system image picker upload as they are. The camera applies its own quality: the value of the prop, or 0.8 on iOS with react-native-image-picker when the prop isn't set. A camera photo then goes through the same compression as the others, so with the prop set it's encoded twice.
Videos and files are never compressed by the SDK.
Scale down large images
compressImageQuality changes the quality, not the size: a 4000 by 3000 photo stays 4000 by 3000. The SDK compresses each image by calling the compressImage native handler with the quality and the image's width, height and uri, and uploads the file it returns. Override the handler to pass a smaller size. Register the override once, at the top level of a module, after the import of the package:
The default handler of stream-chat-react-native resizes the image to the width and height it receives, so wrap it and pass a smaller size:
import {
defaultNativeHandlers,
registerNativeHandlers,
} from "stream-chat-react-native";
const MAX_SIDE = 1600;
const defaultCompressImage = defaultNativeHandlers.compressImage;
if (defaultCompressImage) {
registerNativeHandlers({
compressImage: ({ height, width, ...params }) => {
const scale = Math.min(1, MAX_SIDE / Math.max(width, height));
return defaultCompressImage({
...params,
height: Math.round(height * scale),
width: Math.round(width * scale),
});
},
});
}The default handler of stream-chat-expo changes only the quality and ignores the size, so register a handler that resizes with expo-image-manipulator, which stream-chat-expo already needs:
import { manipulateAsync, SaveFormat } from "expo-image-manipulator";
import { registerNativeHandlers } from "stream-chat-expo";
const MAX_SIDE = 1600;
registerNativeHandlers({
compressImage: async ({ compressImageQuality, height, uri, width }) => {
const scale = Math.min(1, MAX_SIDE / Math.max(width, height));
try {
const { uri: compressedUri } = await manipulateAsync(
uri,
scale < 1
? [
{
resize: {
height: Math.round(height * scale),
width: Math.round(width * scale),
},
},
]
: [],
{ compress: compressImageQuality, format: SaveFormat.JPEG },
);
return compressedUri;
} catch {
// Upload the original image.
return uri;
}
},
});Import the file once, for example from the file that renders OverlayProvider, so the override is registered before the first upload. The handler runs only when compressImageQuality is set to a value other than 1, so set the prop too, for example to 0.8. The RN CLI handler writes a JPEG over a white background, and returns the original uri when it fails. Entry points and native handlers explains overriding handlers.
Compress videos before upload
Videos and other files go to the composer's upload function, attachments.doUploadRequest, unchanged. Register one that compresses large videos and hands the result to the default upload. This one uses react-native-compressor, which you install yourself. It's a native module, so rebuild the app after adding it.
The default upload, AttachmentManager.doDefaultUploadRequest(), belongs to a composer, so register the function in the composer setup function, where the composer is at hand:
import { Video } from "react-native-compressor";
import { isFileReference } from "stream-chat";
import type { StreamChat, UploadRequestFn } from "stream-chat";
const MIN_VIDEO_SIZE = 10 * 1024 * 1024; // 10 MB
export const setUpComposers = (client: StreamChat) =>
client.config.setSetupFunction("messageComposer", ({ composer }) => {
const { attachmentManager } = composer;
const compressAndUpload: UploadRequestFn = async (file, options) => {
const isLargeVideo =
isFileReference(file) &&
file.type.startsWith("video/") &&
// The photo grid of `stream-chat-expo` reports a size of 0.
(file.size === 0 || file.size >= MIN_VIDEO_SIZE);
if (!isFileReference(file) || !isLargeVideo) {
return attachmentManager.doDefaultUploadRequest(file, options);
}
let { uri } = file;
try {
uri = await Video.compress(file.uri, { compressionMethod: "auto" });
} catch {
// Upload the original video.
}
return attachmentManager.doDefaultUploadRequest(
{ ...file, uri },
options,
);
};
composer.updateConfig({
attachments: { doUploadRequest: compressAndUpload },
});
});Call setUpComposers(client) once the client exists, before any Channel renders. There is one setup function per kind of object, so if your app already has a composer setup function, such as the one in the Commands UI cookbook, put the upload function in it.
Here is what happens to a large video:
- The user picks it, and its preview shows the upload running while the video compresses.
compressAndUploadpasses the compressed file'suri, with the originalnameandtype, to the default upload. The default upload sends it to Stream's CDN, reports progress throughoptions.onProgress, and stops when the user removes the attachment.- If compression fails, the original video uploads.
The function receives every file the composer uploads, voice recordings included, so check the type before compressing. AttachmentManager.doDefaultUploadRequest() uploads to Stream, so leave customCdn at false.
When your app has upload settings, the composer checks the file against them, the size limit included, before it calls the upload function. That check sees the original file, so a video over the limit is blocked and removed before it can be compressed.
Compress and upload to your own storage
When the files go to your own storage instead, the upload function doesn't need the default upload, so register it for all composers in client.config. Set customCdn: true, so the composer stops applying the channel type's uploads setting and the upload-file capability to files that never reach Stream:
import { Video } from "react-native-compressor";
import { isFileReference } from "stream-chat";
import type { StreamChat, UploadRequestFn } from "stream-chat";
const uploadToMyStorage: UploadRequestFn = async (file, options) => {
if (!isFileReference(file)) throw new Error("Expected a file reference");
let { uri } = file;
if (file.type.startsWith("video/")) {
try {
uri = await Video.compress(file.uri, { compressionMethod: "auto" });
} catch {
// Upload the original video.
}
}
const body = new FormData();
// React Native's FormData sends a local file given as { uri, name, type }.
body.append("file", { name: file.name, type: file.type, uri });
const response = await fetch("https://uploads.example.com/files", {
body,
method: "POST",
signal: options?.abortSignal,
});
if (!response.ok) throw new Error(`Upload failed with ${response.status}`);
const { url } = (await response.json()) as { url: string };
return { file: url };
};
export const configureUploads = (client: StreamChat) =>
client.config.set({
messageComposer: {
attachments: { customCdn: true, doUploadRequest: uploadToMyStorage },
},
});The function resolves with { file }, the URL to put in the message, and can add thumb_url. Throwing marks the upload as failed, and the preview offers a retry. Pass options.abortSignal to your request, so removing an attachment stops its upload, and call options.onProgress with a percentage if your upload reports progress. Message composer covers the upload states and settings.
compressImageQuality and a compressImage override still run before this function, because the SDK compresses images when they're picked. The app's upload settings, such as the size limit and the allowed file types, still apply with customCdn: true, and the composer checks them against the file before your upload function runs.
Other uploads
The channel image in channel details doesn't go through the composer. ChannelEditDetailsForm takes its own compressImageQuality and doFileUploadRequest. See Uploading the channel image to your own storage.