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.

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 they're known
video yes A file whose MIME type contains video/. asset_url, title (the file name)
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 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: new File([], "beach.jpg", { type: "image/jpeg" }),
    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. AttachmentManager.removeAttachments() and AttachmentManager.upsertAttachments() find attachments by it.
file The file to upload. Only attachments that are uploaded have one.
previewUri A local address of the file, such as a Blob URL, for showing it before it's uploaded.
uploadState Where the upload is, as Uploads explains.
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.

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, dropped or pasted AttachmentManager.uploadFiles(files) Adds each file and uploads it at once, up to the file limit. 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 file limit: check them first. prepare, then postProcess
upload an attachment again, such as a failed one, or one you built 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. One without a localMetadata.id gets a new one on every call. none

An attachment with nothing to upload, such as a product of your own or a giphy, needs only a localMetadata.id, with no file and no uploadState. The composer sends it as it is, counts it as something to send in messageComposer.hasSendableData, and doesn't count it toward the file limit, messageComposer.attachments.maxNumberOfFilesPerMessage. Set the id yourself, so that you can update the attachment or remove it later:

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

const product: LocalAttachment = {
  type: "product",
  title: "Desk lamp",
  image_url: "https://shop.example.com/images/desk-lamp.jpg",
  custom: {},
  localMetadata: { id: crypto.randomUUID() },
};

channel.messageComposer.attachmentManager.upsertAttachments([product]);

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:

The five upload steps for a file the user adds, and an attachment of your own that skips to the send
  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 finished upload, and every attachment with nothing to upload, into the message, without its localMetadata. While an upload is still running, the send is refused by default. With messageComposer.attachments.pendingUploadsEnabled turned on in client.config, the message appears in the list at once with the upload's progress, and its request goes out once the uploads finish. See Sending with pending uploads.

An attachment of your own skips steps 1 to 4. You add it with AttachmentManager.upsertAttachments(), with only a localMetadata.id, 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.

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 app's upload settings don't allow its type or size. 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. It's true only when all of these hold:

  • messageComposer.attachments.enabled in client.config is true.
  • The channel type's uploads setting, set in the Stream Dashboard or through the server API, allows uploads, unless messageComposer.attachments.customCdn in client.config is true.
  • The user has the upload-file capability, or messageComposer.attachments.customCdn in client.config is true.
  • The message has fewer files than the file limit (messageComposer.attachments.maxNumberOfFilesPerMessage in client.config, 10 by default). Uploaded and uploading files count; pending, failed and blocked ones don't.

attachmentManager.availableUploadSlots says how many more files fit under the limit.

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 doesn't count toward the file limit, so retrying it needs room under the limit, and attachmentManager.isUploadEnabled is false when there's none. 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 no longer apply:

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

const uploadToMyStorage: UploadRequestFn = async (file, options) => {
  const body = new FormData();
  body.append("file", file as Blob);

  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 },
  },
});

Sending attachments

When the message is built, the finished uploads and the attachments with nothing to upload go into the message's attachments, in the composer's order. 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. If one of them fails, the message fails. See Sending with pending uploads.

A draft saves the finished uploads and the attachments with nothing to upload. When a draft or a message to edit is loaded into the composer, its files come back as finished, and its other attachments with only a localMetadata.id, so they're sent again.

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 files a message can have. Attachments with nothing to upload don't count.
acceptedFiles [], every type The file types a file picker should offer. stream-chat doesn't check it.
trackUploadProgress true Sets localMetadata.uploadProgress during uploads.
pendingUploadsEnabled false Lets a message be sent while its uploads run, as Sending attachments explains.
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