# Mobile Chat Media — Integration Guide

> Attach **images, GIFs, and files (PDF / DOCX / CSV / XLSX)** to chat messages.
> Companion to `mobile-chat-guide.md` — read that first for send/receive,
> sockets, receipts, and the message object. This doc only adds the media bits.

- API base URL (local): `http://localhost:3001`
- Auth: `Authorization: Bearer <accessToken>` on every endpoint (**required**)
- Headers: `Content-Type: application/json`, `X-Client-Type: mobile`
- Storage upload uses the same **presign → PUT → reference** flow as posts.

> **No video, no audio.** Video and voice notes are not accepted right now.
> Chat attachments are `image`, `gif`, or `file` only.

---

## Allowed types + size caps

| kind | content types | max size |
|---|---|---|
| image | image/jpeg, image/png, image/webp | 10 MB |
| image | image/heic, image/heif (iOS photos) | 15 MB |
| gif | image/gif (or a Klipy picker gif via `remoteUrl`) | 15 MB |
| file | application/pdf, .docx, .xlsx, text/csv | 25 MB |

Anything else — including any `video/*` or `audio/*` — is rejected at presign
(`415`). The server also **sniffs the uploaded bytes** and rejects a file whose
real content doesn't match what you declared (a `.png`-named PDF, etc.).

---

## The flow — one message, three steps

Media does **not** upload through the API. The client uploads bytes straight to
storage, then sends the message referencing the uploaded object. Media rides the
**same send call** as text (one idempotent request — retry-safe on flaky
networks). No separate "attach" round-trip.

```
1. POST /api/media/presign        → get a signed PUT URL + storageKey
2. PUT bytes → uploadUrl          → upload straight to storage (not via API)
3. POST /api/chat/conversations/:id/messages  { media: [{ storageKey, ... }] }
```

### Step 1 — presign

```http
POST /api/media/presign
{ "files": [ { "filename": "photo.jpg", "contentType": "image/jpeg", "size": 348211 } ] }
```

```jsonc
{
  "success": true,
  "data": [
    {
      "uploadUrl": "https://.../signed-put-url",   // PUT bytes here
      "publicUrl": "https://.../media/<you>-...",   // ignore — server resolves URLs
      "key": "media/<yourUserId>-1719-uuid.jpg",    // ← this is your storageKey
      "contentType": "image/jpeg"
    }
  ]
}
```

- **`size` (bytes) is now required** and must be the exact byte length you will
  PUT — the server rejects a value over the cap (`413`) and **pins it into the
  signed URL**.
- Max **4** files per presign call.
- `filename`'s extension decides the object's extension — pass a real one
  (`.jpg`, `.png`, `.gif`, `.pdf`, `.docx`, `.csv`, `.xlsx`).

### Step 2 — PUT the bytes

```
PUT <uploadUrl>
Content-Type: image/jpeg      ← must match the presign contentType
Content-Length: 348211        ← must match the presign size (exact)
<raw bytes>
```

Direct to storage. The API never sees the bytes. **The `Content-Length` must
equal the `size` you presigned** — storage rejects a body of any other length
(`403`). Most HTTP clients set `Content-Length` automatically for a known-size
body. Show upload progress off this PUT.

### Step 3 — send the message

```http
POST /api/chat/conversations/:id/messages
{
  "clientMessageId": "<uuid>",           // idempotency — see chat guide §5.2
  "content": "check this out",            // OPTIONAL when media present
  "media": [
    {
      "storageKey": "media/<yourUserId>-1719-uuid.jpg",  // the `key` from step 1
      "kind": "image",                    // image | gif | file
      "mimeType": "image/jpeg",           // required for files (pdf/docx/csv/xlsx)
      "width": 1290, "height": 1720
    }
  ]
}
```

Response `data` is the normal message object plus `media[]` (see below). The
same object is pushed over the socket as `new_message` — media included.

---

## Not in a message request

Media cannot be sent into a **PENDING** DM. Until the recipient accepts, a
request carries plain text only — `media[]` (image, file **or GIF**) and
`postId` both return **403** *"Only text messages can be sent in a message
request"*.

Hide the attachment and GIF buttons whenever the open conversation has
`status === "PENDING"` and you are the `requesterId`. Restore them on
`conversation_status_changed`. See chat guide §6.4.1.

---

## Send contract — the `media` field

Each item:

| field | required | notes |
|---|---|---|
| `storageKey` | ✅ | The `key` returned by presign. **Not** a URL. Server validates you own it. |
| `kind` | ✅ | `image` \| `gif` \| `file`. Must match the real content (server sniffs). |
| `mimeType` | files ✅ | e.g. `application/pdf`, `text/csv`. **Required for `file` kind** so the server can validate a magic-less type like CSV. |
| `width`, `height` | image | pixels — lets the UI reserve space before load |
| `size` | optional | bytes (display hint) |
| `altText` | optional | ≤ 200 chars; also used as file display name |

Rules:

- **`content` is optional** — but a message must have **text OR at least one
  attachment**. Empty + no media → `400`.
- **Max 4 attachments** per message.
- **Ownership** — `storageKey` must be a key **you** presigned (they're prefixed
  with your user id). Sending someone else's key or an arbitrary path → `403`.
- **Content is verified** — the server sniffs the bytes; if the real type doesn't
  match `kind`/`mimeType` (or isn't allowed in chat), the send is rejected `415`.
- Metadata (`width`/`height`/`size`) is treated as a **hint**.

### Picker GIFs (Klipy)

A picker gif is sent with `remoteUrl` (the Klipy URL) instead of `storageKey`,
`kind: "gif"`, and no owned object. The server validates the host + a 15 MB /
image-type HEAD check. Exactly one of `storageKey` **or** `remoteUrl`.

---

## Message object — `media[]` (read + socket)

`GET /conversations/:id/messages` and the `new_message` socket event carry
`media` on every message:

```jsonc
{
  "id": "msg_xxx",
  "content": "",                 // "" for a media-only message
  "type": "MEDIA",               // MESSAGE-level: TEXT | MEDIA | POST_SHARE | SYSTEM | CALL
  "senderId": "...",
  "media": [
    {
      "id": "med_xxx",           // stable handle — key your UI by this
      "type": "image",           // ATTACHMENT kind (image | gif | file)
      "url": "https://...",       // ready to render — see "URLs" below
      "mimeType": "image/jpeg",
      "width": 1290,
      "height": 1720,
      "altText": null,
      "variants": []             // image thumbnails once processed (see below)
    }
  ],
  "status": { /* delivery/read — chat guide §5.5 */ }
}
```

### Two different `type` fields — don't confuse them

- **`message.type`** = message category: `TEXT`, `MEDIA`, `POST_SHARE`,
  `SYSTEM`, `CALL`. `MEDIA` = attachments only, no text. Text present → `TEXT`
  **even with attachments**. `POST_SHARE` is a shared-post card (chat guide
  §5.10) and `SYSTEM` is a server-authored lifecycle event (see
  `mobile-chat-system-messages-guide.md`) — neither ever carries attachments.
  Never infer the attachment kind from this.
- **`media[i].type`** = the attachment kind (`image`/`gif`/`file`). Note the send
  field is called **`kind`** but the read field is **`type`** — same values.

To know "does this message have attachments," check `media.length > 0` — not
`message.type`.

### Thumbnails / variants (image only)

Image attachments get resized `variants` generated **async** after send. On
first render `variants` may be `[]` and `url` is the original — fine to show.
gif / file never get variants. Prefer a `variant` (thumbnail/small) for list
previews when present; fall back to `url`.

> **Merge, don't replace, when the message updates.** A media message is slower
> to round-trip than text (bytes upload first), so a `message_delivered` /
> `messages_read` for it can arrive **before** your own `new_message` echo
> reconciles — and any later refetch that swaps in the processed `variants`
> comes even later. If you overwrite the cached row wholesale on either step,
> you wipe the `status` a receipt already advanced and the read/delivery tick
> "only shows on refresh." Splice in the new `media`/`variants`, keep the live
> `status`. Full rule + merge helper: chat guide §5.5.1.

---

## URLs — treat as opaque + refetchable

`url` is ready to drop into `<img>` (or a file/document viewer). But:

- **Key attachments by `id`, not by `url`.** Do **not** persist the URL
  long-term or hard-code it.
- URLs may become **short-lived signed URLs** in a future release (privacy). If
  you cache a URL and it later expires, refetch the message to get a fresh one.
  Nothing else about your integration changes.

---

## Files (PDF / DOCX / CSV / XLSX)

Files are `kind: "file"` — same presign → PUT → send flow.

1. Presign with the file's real `contentType` (e.g. `application/pdf`) and `size`.
2. PUT with a matching `Content-Type` + `Content-Length`.
3. Send `{ kind: "file", storageKey, mimeType, altText: "<filename>" }`.
   **`mimeType` is required** for files — CSV has no magic number, so the server
   needs the declared type to validate it.
4. Render a file chip (icon + `altText` name); open `url` in a viewer / share
   sheet on tap.

> **Video and voice notes are not supported right now.** `video/*` and `audio/*`
> presigns are rejected (`415`). Don't offer a record-audio or attach-video path.

---

## Editing media

Media is **delete-only** on a message you've sent — you can't swap or add
attachments after sending (to add more, send a new message).

- **Text/caption edit** — unchanged: `PATCH /api/chat/messages/:messageId
  { "content": "..." }` (15-min window, text only). See chat guide §5.7.
- **Remove one attachment:**

```http
DELETE /api/chat/messages/:messageId/media/:mediaId
```

- Author only, within the **15-minute** window (same as edit).
- Deletes the attachment + its stored bytes.
- **Blocks removing the last attachment of a text-less message** → `400`
  ("Delete the message instead"). A message can't end up empty.
- Emits `message_edited` on the socket with `mediaRemovedId` (content
  unchanged) — splice that attachment out of your local copy by `id`.

---

## Delete lifecycle (what happens to the bytes)

| action | message | attachments | stored bytes |
|---|---|---|---|
| Remove one attachment (author, ≤15 min) | kept | that one gone | that object deleted |
| **Soft-delete message** (`DELETE .../messages/:id`) | tombstone ("This message was deleted") | **all removed** | **all deleted** |
| Decline a message request | conversation gone | gone | deleted |

**A deleted message's media is gone for good** — its `url`s stop working. Drop
any cached thumbnails when you receive `message_deleted`.

---

## Errors

| code | when |
|---|---|
| 400 | message has neither text nor media; or removing the last attachment of a text-less message |
| 401 | missing / invalid token |
| 403 | `storageKey` isn't one you presigned (ownership); PUT `Content-Length` ≠ presigned `size`; not the sender on edit/remove; edit window expired; or **attaching anything to an unaccepted message request** |
| 404 | message or attachment not found |
| 413 | declared `size` (or the stored object) exceeds the per-type cap |
| 415 | unsupported content type (e.g. video/audio), or the uploaded bytes don't match the declared `kind`/`mimeType` |
| 429 | send rate limit (60/min/user) — see chat guide §5 |

---

## Checklist

- [ ] Upload via **presign → PUT → send**; never POST bytes through the API.
- [ ] Send `size` on presign; PUT with a matching `Content-Type` **and**
      `Content-Length`.
- [ ] Send `media[].storageKey` (the presign `key`), never a raw URL. Send
      `kind`, plus `mimeType` for files and `width`/`height` for images.
- [ ] Only offer image / gif / file (PDF/DOCX/CSV/XLSX) — **no video, no audio**.
- [ ] Offer none of them at all on a PENDING message request you sent — it's
      text-only until accepted.
- [ ] Enforce the caps client-side (image 10MB, gif 15MB, file 25MB) before PUT.
- [ ] Allow media-only messages (omit `content`); block send when both empty.
- [ ] Cap at **4 attachments**.
- [ ] Render `message.media[]`; key each attachment by `id`. Distinguish
      attachments via `media.length`, not `message.type`.
- [ ] Treat `url` as opaque + refetchable — don't persist it; refetch on expiry.
- [ ] Prefer image `variants` (thumbnails) for previews; fall back to `url`
      while `variants` is empty.
- [ ] Wire remove-attachment (`DELETE .../messages/:id/media/:mediaId`); handle
      the `400` last-attachment case; update on `message_edited` +
      `mediaRemovedId`.
- [ ] On `message_deleted`, drop cached media — the bytes are gone.
