# 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](https://getstream.io/chat/docs/sdk/react/state/writing-messages/link-previews/).

## 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](https://getstream.io/chat/docs/sdk/react/state/state-and-entities/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:

**image**

```ts
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",
  },
};
```

**video**

```ts
import type { LocalVideoAttachment } from "stream-chat";

const video: LocalVideoAttachment = {
  type: "video",
  asset_url: "https://us-east.stream-io-cdn.com/1234/attachments/trip.mp4",
  thumb_url: "https://us-east.stream-io-cdn.com/1234/images/trip-thumb.jpg",
  title: "trip.mp4",
  custom: { file_size: 8204311, mime_type: "video/mp4" },
  localMetadata: {
    id: "9b7e1d24-3c8f-4a61-b2d0-5e6f7a8b9c01",
    file: new File([], "trip.mp4", { type: "video/mp4" }),
    uploadState: "finished",
  },
};
```

**audio**

```ts
import type { LocalAudioAttachment } from "stream-chat";

const audio: LocalAudioAttachment = {
  type: "audio",
  asset_url: "https://us-east.stream-io-cdn.com/1234/attachments/podcast.mp3",
  title: "podcast.mp3",
  custom: { file_size: 3411002, mime_type: "audio/mpeg" },
  localMetadata: {
    id: "2c4d6e8f-1a3b-4c5d-8e9f-0a1b2c3d4e5f",
    file: new File([], "podcast.mp3", { type: "audio/mpeg" }),
    uploadState: "finished",
  },
};
```

**file**

```ts
import type { LocalFileAttachment } from "stream-chat";

const file: LocalFileAttachment = {
  type: "file",
  asset_url: "https://us-east.stream-io-cdn.com/1234/attachments/agenda.pdf",
  title: "agenda.pdf",
  custom: { file_size: 120577, mime_type: "application/pdf" },
  localMetadata: {
    id: "7a8b9c0d-1e2f-4a3b-9c4d-5e6f7a8b9c0d",
    file: new File([], "agenda.pdf", { type: "application/pdf" }),
    uploadState: "finished",
  },
};
```

**voiceRecording**

```ts
import type { LocalVoiceRecordingAttachment } from "stream-chat";

const recording: LocalVoiceRecordingAttachment = {
  type: "voiceRecording",
  asset_url: "https://us-east.stream-io-cdn.com/1234/attachments/recording.mp3",
  title: "recording.mp3",
  custom: {
    duration: 4.2,
    file_size: 33602,
    mime_type: "audio/mp3",
    waveform_data: [0.12, 0.4, 0.85, 0.6, 0.3, 0.1],
  },
  localMetadata: {
    id: "5d6e7f80-9a1b-4c2d-8e3f-4a5b6c7d8e9f",
    file: new File([], "recording.mp3", { type: "audio/mp3" }),
    uploadState: "finished",
  },
};
```

**giphy**

A Giphy attachment comes from the `/giphy` command: the server adds it to the message, and the attachment manager never uploads one. This is how a sent message carries it:

```ts
import type { GiphyAttachment } from "stream-chat";

const size = (width: string, height: string, url: string) => ({
  frames: "24",
  height,
  size: "301233",
  url,
  width,
});

const giphy: GiphyAttachment = {
  type: "giphy",
  custom: {},
  title: "hello",
  title_link: "https://giphy.com/gifs/hello-3o7TKSjRrfIPjeiVyM",
  thumb_url: "https://media.giphy.com/media/3o7TKSjRrfIPjeiVyM/giphy.gif",
  giphy: {
    original: size(
      "480",
      "270",
      "https://media.giphy.com/media/3o7TKSjRrfIPjeiVyM/giphy.gif",
    ),
    fixed_height: size(
      "356",
      "200",
      "https://media.giphy.com/media/3o7TKSjRrfIPjeiVyM/200.gif",
    ),
    fixed_height_downsampled: size(
      "356",
      "200",
      "https://media.giphy.com/media/3o7TKSjRrfIPjeiVyM/200_d.gif",
    ),
    fixed_height_still: size(
      "356",
      "200",
      "https://media.giphy.com/media/3o7TKSjRrfIPjeiVyM/200_s.gif",
    ),
    fixed_width: size(
      "200",
      "113",
      "https://media.giphy.com/media/3o7TKSjRrfIPjeiVyM/200w.gif",
    ),
    fixed_width_downsampled: size(
      "200",
      "113",
      "https://media.giphy.com/media/3o7TKSjRrfIPjeiVyM/200w_d.gif",
    ),
    fixed_width_still: size(
      "200",
      "113",
      "https://media.giphy.com/media/3o7TKSjRrfIPjeiVyM/200w_s.gif",
    ),
  },
};
```

A shared location isn't an attachment: it goes out in the message's `shared_location` field, as [Location](https://getstream.io/chat/docs/sdk/react/state/writing-messages/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](#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](https://getstream.io/chat/docs/sdk/react/state/writing-messages/text/#changing-the-text):

- `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](https://getstream.io/chat/docs/sdk/react/state/writing-messages/composer-middleware/#uploads) 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](#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](#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:

```ts
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](https://getstream.io/docs-assets/images/3da2d51dd295.png)

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](#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](https://getstream.io/chat/docs/sdk/react/features/messaging/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](#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](#settings) 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](#adding-and-removing-attachments) 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:

```ts
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:

```ts
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](https://getstream.io/chat/docs/sdk/react/features/messaging/sending-with-pending-uploads/).

A [draft](https://getstream.io/chat/docs/sdk/react/state/writing-messages/message-composer/#drafts) 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`:

```ts
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](#sending-attachments) explains.                          |
| `doUploadRequest`            | Stream's upload  | Uploads to your own storage, as [Uploading to your own CDN](#uploading-to-your-own-cdn) explains.                               |
| `customCdn`                  | `false`          | `true` when `doUploadRequest` stores files outside Stream, as [Uploading to your own CDN](#uploading-to-your-own-cdn) explains. |

## Used in

- [Attachments and uploads](https://getstream.io/chat/docs/sdk/react/features/rich-content/attachments-and-uploads/)
- [Sending with pending uploads](https://getstream.io/chat/docs/sdk/react/features/messaging/sending-with-pending-uploads/)

---

For the most recent version of this documentation, visit [https://getstream.io/chat/docs/sdk/react/state/writing-messages/attachments/](https://getstream.io/chat/docs/sdk/react/state/writing-messages/attachments/).