Attachments

An attachment is a file, or other content, that goes out with a message: an image, a video, an audio file, a voice recording or any other file, or content you build without an upload. While the user writes the message, the composer keeps its attachments in the attachment manager, messageComposer.attachmentManager. Previews of links in the text are kept apart, by the link previews manager.

What it holds

The attachment manager's state is in attachmentManager.state. Its one field, attachments, is the list of local attachments. A local attachment is the attachment the message will carry, plus a localMetadata field that the composer uses while the message is written. attachmentManager.attachments reads the current list.

In React Native, a file is a FileReference: an object with the file's uri, name, type (its MIME type) and size, as the SDK's pickers return it. A FileReference can also carry height and width for an image, thumb_url for a video and duration for a voice recording.

Each attachment has a type. For a file the user adds, the attachment manager sets it from the MIME type, and fills in the fields below. The URL fields are set when the upload finishes:

type Uploaded What it is Fields
image yes A file whose MIME type starts with image/, except Photoshop files. image_url, fallback (the file name), original_width and original_height when the file reference has width and height
video yes A file whose MIME type contains video/. asset_url, title (the file name), thumb_url when the file reference has one
audio yes A file whose MIME type contains audio/. asset_url, title
file yes Any other file. asset_url, title
voiceRecording yes A recording made in the app. asset_url, title, custom.duration, custom.waveform_data
any other, such as product no An attachment of your own, added with AttachmentManager.upsertAttachments(). Whichever you set. Data of your own goes in custom, typed by adding its fields to CustomAttachmentData, as Data conventions explains.

Every type that comes from a file also gets custom.file_size and custom.mime_type, and thumb_url when the upload returns a thumbnail.

Here is what each one looks like in attachmentManager.attachments once its upload has finished:

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

const image: LocalImageAttachment = {
  type: "image",
  image_url: "https://us-east.stream-io-cdn.com/1234/images/beach.jpg",
  fallback: "beach.jpg",
  original_width: 1600,
  original_height: 1200,
  custom: { file_size: 482133, mime_type: "image/jpeg" },
  localMetadata: {
    id: "4f1c2a90-6b3e-4d55-9a1e-0c7b2f8d3e11",
    file: {
      name: "beach.jpg",
      size: 482133,
      type: "image/jpeg",
      uri: "file:///data/user/0/com.example/cache/beach.jpg",
    },
    uploadState: "finished",
  },
};

A shared location isn't an attachment: it goes out in the message's shared_location field, as Location explains.

localMetadata holds:

Field What it holds
id The attachment's id in the composer. The only field every local attachment has. AttachmentManager.removeAttachments() and AttachmentManager.upsertAttachments() find attachments by it.
file The FileReference to upload. Only attachments that are uploaded have one, and an attachment restored from a draft or a message to edit has none.
previewUri A local address of the file, the uri of its FileReference, for showing it before it's uploaded. It's removed when the upload finishes.
uploadState Where the upload is, as Uploads explains. An attachment with nothing to upload has none.
uploadProgress How far the upload is, from 0 to 100, while progress tracking is on.
uploadConfirmationPending true when the whole file is sent but the server hasn't confirmed it yet. Show progress without a percentage then.
uploadPermissionCheck Why the upload was blocked: { uploadBlocked, reason }, where reason names the upload setting that refused it.

Fields of your own can go into localMetadata too. They stay in the composer and are never sent.

A draft or a message to edit that is loaded into the composer brings its attachments back. An uploaded one, such as an image or a video, comes back with uploadState: 'finished' and no file, so a component that renders localMetadata.file has to allow for a missing one. The other attachments come back with only an id.

Adding and removing attachments

A file the user adds goes through two attachment middleware pipelines, which work like the text middleware:

  • prepare runs before the upload, in attachmentManager.preUploadMiddlewareExecutor. It checks the file against the app's upload settings and can block it. To accept only some files, add a prepare function that discards the others.
  • postProcess runs after the upload, in attachmentManager.postUploadMiddlewareExecutor, and sets the attachment's URL.

Composer middleware lists the built-in functions. The last column says which pipelines a call runs.

What an attachment picker and its previews need, and the call for each:

To... Call What happens Attachment middleware
add files the user picked or took AttachmentManager.uploadFiles(files) Adds each file and uploads it at once, up to the free slots. Does nothing while attachmentManager.isUploadEnabled is false, as Uploads explains. prepare, then postProcess
add one file AttachmentManager.uploadFile(file) Adds the file and uploads it at once. It doesn't check attachmentManager.isUploadEnabled or the free slots: check them first. prepare, then postProcess
upload an attachment again, such as a failed one AttachmentManager.uploadFile(attachment) Uploads it in place, as Retrying a failed upload explains. It doesn't check attachmentManager.isUploadEnabled: check it first. prepare, then postProcess
remove attachments AttachmentManager.removeAttachments(localIds) Removes them by localMetadata.id and cancels their uploads. none
add an attachment that needs no upload, such as one built from a URL AttachmentManager.upsertAttachments(attachments) Adds them, or merges them into the ones with the same localMetadata.id. An attachment without a local id gets a new one each time, so it can't be updated later. none

The SDK's pickers and the camera go through useMessageInputContext().uploadNewFile(file). It gives a file with no MIME type one worked out from its name, compresses an image with compressImageQuality, calls AttachmentManager.uploadFiles(), and removes an attachment that came back blocked, after the notification for it was published. pickFile(), pickAndUploadImageFromNativePicker() and takeAndUploadImage() of the same context call it, and they stop with an alert when attachmentManager.availableUploadSlots is 0.

The composer sends an attachment that has nothing to upload as it is. Give it only the localMetadata.id that identifies it, with no file and no uploadState:

import { generateRandomId } from "stream-chat-react-native";
import type { LocalAttachment, MessageComposer } from "stream-chat";

export const addProductCard = (composer: MessageComposer) => {
  const product: LocalAttachment = {
    type: "product",
    title: "Desk lamp",
    image_url: "https://shop.example.com/images/desk-lamp.jpg",
    custom: {},
    localMetadata: { id: generateRandomId() },
  };

  composer.attachmentManager.upsertAttachments([product]);
};

The predicates isNonUploadAttachment(), isFinishedUpload(), isSendableAttachment() and isResolvableUpload() are exported from stream-chat. An attachment is sendable when its upload is finished or when it has nothing to upload. isResolvableUpload() is true for an upload that is pending or uploading, which a send can wait for, and for one that is failed, which a send retries, as Sending attachments explains.

Uploads

Only files go through an upload: the image, video, audio, file and voiceRecording attachments. An upload puts the file on a CDN, so that the message can carry a link to it, in five steps:

  1. The file is added. Call AttachmentManager.uploadFiles(files). The attachment manager creates a local attachment for each file, sets its type from the MIME type, and gives it a previewUri, so a UI can show the file at once. Its uploadState is pending.
  2. The file is checked. The prepare middleware compares the file with the app's upload settings, such as the allowed types and size. A file they don't allow becomes blocked and is never uploaded.
  3. The file is uploaded, to Stream's CDN, or to your own storage, as Uploading to your own CDN explains. Its uploadState is uploading, and localMetadata.uploadProgress follows it.
  4. The URL is added to the attachment. The postProcess middleware writes the URL the upload returned into image_url or asset_url, and thumb_url when there is one. The uploadState becomes finished. If the upload fails, it becomes failed.
  5. The message is sent. Call MessageComposer.send(). It puts every sendable attachment into the message, without its localMetadata. While an upload is still running, the send waits or is refused, as Sending attachments explains.

An attachment of your own skips steps 1 to 4. You add it with AttachmentManager.upsertAttachments(), as Adding and removing attachments shows, and step 5 sends it with the rest. To drop a file at any step, call AttachmentManager.removeAttachments([id]), which also cancels its upload.

The SDK's audio recorder makes a voiceRecording attachment from a recording. Unless audioRecordingSendOnComplete is on, it uploads it with AttachmentManager.uploadFile(), while attachmentManager.isUploadEnabled is true. With it on, the recorder adds the attachment as a pending upload and sends the message, so the send has to upload it, which needs the pendingUploadsEnabled setting.

Each attachment's localMetadata.uploadState tells where its upload is:

  • pending: added, not uploading yet.
  • uploading: on its way. localMetadata.uploadProgress goes from 0 to 100 while the trackUploadProgress setting is on, which is the default.
  • finished: uploaded, with its URL set.
  • failed: the request failed, and an api:attachment:upload:failed notification was published.
  • blocked: refused before uploading, because the Stream app's upload settings refuse it. These are set in the Dashboard: the allowed and blocked file types, and the size limit, 100 MB when none is set. A validation:attachment:upload:blocked notification was published.

These getters count and list the attachments in each state:

uploadState Count Attachments
pending attachmentManager.pendingUploadsCount AttachmentManager.getUploadsByState("pending")
uploading attachmentManager.uploadsInProgressCount AttachmentManager.getUploadsByState("uploading")
finished attachmentManager.successfulUploadsCount attachmentManager.successfulUploads
failed attachmentManager.failedUploadsCount AttachmentManager.getUploadsByState("failed")
blocked attachmentManager.blockedUploadsCount AttachmentManager.getUploadsByState("blocked")

attachmentManager.isUploadEnabled says whether a file can be added now. Three things must hold: attachments are enabled, which also needs the channel type's uploads setting unless customCdn is set; a slot is free under the maxNumberOfFilesPerMessage setting; and the user has the upload-file capability, unless customCdn is set. attachmentManager.availableUploadSlots says how many more files fit. Each finished or running upload takes a slot. Pending, failed and blocked uploads, and attachments with nothing to upload, take none.

In a component, useAttachmentManagerState() returns the attachments with these counts, isUploadEnabled and availableUploadSlots.

Retrying a failed upload

AttachmentManager.uploadFile() also takes an attachment already in the composer, and uploads it again in place. The attachment keeps its localMetadata.id, its fields, such as a voice recording's custom.waveform_data and custom.duration, and its position in attachmentManager.attachments. The retry runs the attachment middleware again, so your own prepare and postProcess middleware runs on the retry too. It uploads localMetadata.file. If a prepare middleware of yours replaced the file on the first try, for example with a compressed image, it now runs on that replaced file, so make it skip a file it already changed.

AttachmentManager.uploadFile() also uploads an attachment you build yourself, with fields you set before the upload. The SDK's voice recorder works this way: it builds a voiceRecording attachment with custom.waveform_data and custom.duration, and uploads it with AttachmentManager.uploadFile(). Give the attachment's localMetadata the file and an uploadState, such as 'pending': a file without an uploadState doesn't compile. The attachment manager adds it and uploads localMetadata.file, and the attachment keeps the fields you set.

AttachmentManager.uploadFile() doesn't check attachmentManager.isUploadEnabled, so a retry button of your own checks it first. A failed attachment takes no upload slot, so retrying it needs a free one, and attachmentManager.isUploadEnabled is false when none is left. The SDK's attachment preview list retries this way and does nothing while attachmentManager.isUploadEnabled is false. AttachmentManager.uploadFile() always resolves to the attachment, so read its localMetadata.uploadState to know whether the upload worked:

import type { LocalUploadAttachment, MessageComposer } from "stream-chat";

const retryUpload = async (
  composer: MessageComposer,
  attachment: LocalUploadAttachment,
) => {
  const { attachmentManager } = composer;
  if (!attachmentManager.isUploadEnabled) return false;
  const result = await attachmentManager.uploadFile(attachment);
  return result.localMetadata.uploadState === "finished";
};

An attachment without localMetadata.file or localMetadata.id has nothing to upload. AttachmentManager.uploadFile() returns it unchanged and publishes an error notification.

Uploading to your own CDN

By default, files go to Stream's CDN. To upload them to your own storage, register an upload request. It resolves with the file's URL, and it can report progress and stop when the user removes the file. Set customCdn: true when the files never reach Stream, so that Stream's uploads setting and the upload-file capability don't apply:

import { isFileReference, StreamChat } from "stream-chat";
import type { UploadRequestFn } from "stream-chat";

const client = StreamChat.getInstance("YOUR_API_KEY");

const uploadToMyStorage: UploadRequestFn = async (file, options) => {
  if (!isFileReference(file)) throw new Error("Expected a file reference");

  const body = new FormData();
  body.append("file", { name: file.name, type: file.type, uri: file.uri });

  const response = await fetch("https://uploads.example.com/files", {
    body,
    method: "POST",
    signal: options?.abortSignal,
  });
  const { url } = await response.json();

  return { file: url };
};

client.config.set({
  messageComposer: {
    attachments: { customCdn: true, doUploadRequest: uploadToMyStorage },
  },
});

Attachments and uploads explains how the SDK uploads through the platform's native uploader instead.

Sending attachments

When the message is built, the attachments that are sendable go into the message's attachments, in the composer's order: the ones whose upload is finished, and the ones with nothing to upload. localMetadata is left out, so the server receives each attachment without it. Blocked attachments, and by default failed ones, aren't sent.

By default a message can't be sent while one of its uploads is running: MessageComposer.send() publishes a validation:attachment:upload:in-progress warning and returns 'nothing-to-send'. With messageComposer.attachments.pendingUploadsEnabled turned on in client.config, the message appears in the list at once and its request waits for the uploads, and a failed upload is retried. If one of them fails, the message fails. <Chat enableOfflineSupport> turns pendingUploadsEnabled on, unless your app has set it in client.config. See Sending with pending uploads.

A draft saves the sendable attachments. When a draft or a message to edit is loaded into the composer, its attachments come back, as What it holds explains.

Settings

Set them for every composer in client.config, under messageComposer.attachments:

client.config.set({
  messageComposer: {
    attachments: { maxNumberOfFilesPerMessage: 5, trackUploadProgress: false },
  },
});

To change a setting for one composer only, set it on that composer's attachment manager, for example channel.messageComposer.attachmentManager.maxNumberOfFilesPerMessage = 3. doUploadRequest and trackUploadProgress are the exception: every upload in a channel reads them from the channel's composer, so set them in client.config.

Setting Default What it does
enabled true false turns off attachments. Combined with the channel type's uploads setting, unless customCdn is set.
maxNumberOfFilesPerMessage 10 How many attachments a message can have.
acceptedFiles [], every type The file types a file picker should offer. Neither stream-chat nor the SDK's pickers check it.
fileUploadFilter takes every file Returns false for a file that shouldn't be added. Deprecated: a prepare function that discards the file does the same.
trackUploadProgress true Sets localMetadata.uploadProgress during uploads.
pendingUploadsEnabled false Lets a message be sent while its uploads run, as Sending attachments explains. <Chat enableOfflineSupport> turns it on.
doUploadRequest Stream's upload Uploads to your own storage, as Uploading to your own CDN explains.
customCdn false true when doUploadRequest stores files outside Stream, as Uploading to your own CDN explains.

Used in