# Mobile Chat Reactions & GIFs — Integration Guide

> Emoji **reactions** on messages + a **GIF picker**. Companion to
> `mobile-chat-guide.md` (read that first for send/receive, sockets, receipts,
> the message object) and `mobile-chat-media-guide.md` (attachments). This doc
> only adds reactions and GIFs.

- API base URL (local): `http://localhost:3001`
- Auth: `Authorization: Bearer <accessToken>` on every endpoint (**required**)
- Headers: `Content-Type: application/json`, `X-Client-Type: mobile`

---

# Part 1 — Reactions

One reaction **per user per message** (WhatsApp-style). Tapping a different emoji
**replaces** yours; tapping the same emoji **clears** it (toggle).

## The emoji set (fixed, curated)

The API transports an **emoji key** (stable enum), not the glyph — your client
renders the glyph. The set is fixed (kids app); reject anything else client-side.

| key | glyph | key | glyph | key | glyph |
|---|---|---|---|---|---|
| `THUMBS_UP` | 👍 | `SPARKLES` | ✨ | `THINKING` | 🤔 |
| `HEART` | ❤️ | `CHECK` | ✅ | `SAD` | 😢 |
| `LAUGH` | 😂 | `IDEA` | 💡 | `PRAY` | 🙏 |
| `SMILE` | 😊 | `BOOK` | 📚 | `WAVE` | 👋 |
| `WOW` | 😮 | `BRAIN` | 🧠 | `HANDSHAKE` | 🤝 |
| `CLAP` | 👏 | `TROPHY` | 🏆 | | |
| `PARTY` | 🎉 | `TARGET` | 🎯 | | |
| `FIRE` | 🔥 | `EYES` | 👀 | | |
| `STAR` | ⭐ | `HUNDRED` | 💯 | | |
| `MUSCLE` | 💪 | `ROCKET` | 🚀 | | |

25 keys total. The set is the source of truth in `@ksn/validators`
(`REACTION_EMOJI`) — fetch/mirror it rather than hard-coding, so the picker stays
in sync when the set changes.

## Set / toggle a reaction

```http
PUT /api/chat/messages/:messageId/reaction
{ "emoji": "HEART" }        // one of the keys above
```

Toggle semantics — the **same** call does all three transitions:

- no reaction yet → **adds** `HEART`
- you already reacted `HEART` → **clears** it
- you already reacted `LAUGH` → **changes** to `HEART`

Response — the message's reaction aggregate **for you** (server is source of
truth; just replace your local array with this):

```jsonc
{
  "success": true,
  "data": {
    "reactions": [
      { "emoji": "HEART", "count": 3, "mine": true },
      { "emoji": "LAUGH", "count": 1, "mine": false }
    ]
  }
}
```

- `count` — total reactors for that emoji.
- `mine` — whether **you** are one of them (drives the highlighted chip).
- A cleared emoji drops out of the array (it won't appear with `count: 0`).

## Reading reactions

`GET /conversations/:id/messages` and the `new_message` socket event carry
`reactions` on every message (same `{ emoji, count, mine }[]` shape; `[]` when
none). Render chips under the bubble; highlight where `mine` is true.

```jsonc
{
  "id": "msg_xxx",
  "content": "react to me",
  "reactions": [ { "emoji": "HEART", "count": 2, "mine": true } ],
  "media": [ /* ... */ ],
  "status": { /* ... */ }
}
```

## Realtime — `message_reaction` socket event

When anyone reacts in a conversation you're in, the server emits:

```jsonc
// event: "message_reaction"
{
  "messageId": "msg_xxx",
  "conversationId": "conv_xxx",
  "reactions": [ { "emoji": "HEART", "count": 3 } ]   // counts ONLY — no `mine`
}
```

**`mine` is intentionally absent** — one room broadcast can't carry a per-viewer
flag. On receipt: **update the counts, keep your own `mine`**:

```
incoming.reactions.map(r => ({
  emoji: r.emoji,
  count: r.count,
  mine: myCurrentReactions.some(x => x.emoji === r.emoji && x.mine)  // preserve
}))
```

Only the actor's `mine` changes, and the actor already knows it from its own
optimistic update + the PUT response. If the message isn't in your cache yet,
**drop the event** — the reactions ride in when you load the message.

## Optimistic UI (recommended)

1. On tap, apply the toggle locally (add/replace/clear the chip) immediately.
2. Fire the PUT.
3. On success, replace with `data.reactions` (authoritative).
4. **On error, roll back** to the pre-tap state — otherwise a chip gets stuck.

## Notifications

The message **sender** gets a `message_reaction` notification only on the
**first** reaction from a user (never on change/clear, never self). Rapid emoji
cycling notifies at most once. In-app only by default (no push) — low-noise.

## Reaction errors

| code | when |
|---|---|
| 400 | `emoji` not in the fixed set; or reacting to a **deleted** message |
| 401 | missing / invalid token |
| 403 | you're not an active participant of the conversation |
| 404 | message not found |
| 429 | too many reaction calls (30 / 10s / user) |

---

# Part 2 — GIF picker

Two independent pieces:

1. **Discover** — search / default feed via our server-side proxy (provider key
   stays server-side; you never call the GIF provider directly).
2. **Send** — a picked GIF is an **external URL**, sent inline as media with
   `remoteUrl` (no upload / no presign — unlike images/video/voice).

## Discover — search & default feed

```http
GET /api/chat/gifs/search?q=cat&limit=20&page=1
GET /api/chat/gifs/trending?limit=20&page=1      // the default (no-query) feed
```

- `q` — required on `search` (400 if empty). Not used by `trending`.
- `limit` — page size, **capped server-side at 20**.
- `page` — 1-based; omit for the first page.

Response — a normalized, provider-agnostic list:

```jsonc
{
  "success": true,
  "data": {
    "results": [
      {
        "id": "7810183621082255",
        "url": "https://static.klipy.com/.../m1yDUjBO.gif",       // full — send + render
        "previewUrl": "https://static.klipy.com/.../iQ9oIU35.gif", // small — grid thumb
        "width": 480,
        "height": 264
      }
    ],
    "next": "2"     // next page number as a string, or null when exhausted
  }
}
```

- Render the grid off `previewUrl` (small); send the `url` (full).
- Paginate by passing `next` back as `page` (infinite scroll).
- **Debounce** search input (~300 ms). Load `trending` when the picker opens with
  an empty query.
- **Attribution required**: show "Powered by KLIPY" in the picker (provider ToS).

> **Not in a message request.** A PENDING DM is text-only until the recipient
> accepts — a picker GIF is `media[]` like any other attachment, so it returns
> **403** *"Only text messages can be sent in a message request"*. Hide the GIF
> button on a pending outgoing request (chat guide §6.4.1).

## Send a picked GIF

Send inline as media with **`remoteUrl`** (the result's `url`) — **not**
`storageKey`, **no** presign:

```http
POST /api/chat/conversations/:id/messages
{
  "clientMessageId": "<uuid>",
  "media": [
    { "kind": "gif", "remoteUrl": "https://static.klipy.com/.../m1yDUjBO.gif" }
  ]
}
```

- `remoteUrl` is **gif-only** and mutually exclusive with `storageKey`.
- Server validates the host (must be the GIF provider, https) and stores it; the
  message comes back (and rides `new_message`) with `media: [{ type: "gif", url, ... }]`
  exactly like any other attachment — render it as an image (`<img>` / animated).
- `content` optional (gif-only message is fine); everything else about the send
  is identical to `mobile-chat-media-guide.md`.

> **Uploaded** GIFs (a `.gif` file the user picks from their gallery) still go
> through the normal **presign → PUT → send** flow with `kind: "gif"` +
> `storageKey`. `remoteUrl` is only for GIFs picked from the search proxy.

## Kids-safety (what the server already enforces)

- The **default feed** (`/gifs/trending`) is a safe-rated search on a rotating
  seed — the raw unfiltered provider trending is **never** exposed. Search is
  safe-rated too. Non-GIF items (ads/clips) are stripped server-side.
- Provider-side content + keyword blocking is configured in the provider
  dashboard (server ops), not the client. You don't add safety params — just use
  the two endpoints above.

## GIF errors

| code | when |
|---|---|
| 400 | `search` with empty `q`; or send with both `storageKey` **and** `remoteUrl`; or `remoteUrl` with `kind` ≠ `gif` |
| 401 | missing / invalid token |
| 403 | `remoteUrl` host not allowed / not https (only provider URLs from the search proxy are accepted) |
| 429 | too many search/trending calls (60 / 60s / user) |
| 502 | GIF provider unavailable / timed out — show "GIFs unavailable, try again" |
| 503 | GIF search not configured (server missing the provider key) — hide the GIF button |

---

## Checklist

**Reactions**

- [ ] Render the fixed emoji picker (25 keys, from `REACTION_EMOJI`); send the
      **key** (`HEART`), render the glyph.
- [ ] `PUT .../messages/:id/reaction { emoji }` — same call adds / replaces / clears.
- [ ] Optimistic toggle + **rollback on error**; replace with `data.reactions` on success.
- [ ] Render `message.reactions[]` chips; highlight where `mine`.
- [ ] Handle `message_reaction` socket: update counts, **preserve your own `mine`**;
      drop the event if the message isn't cached yet.

**GIFs**

- [ ] Open picker → `GET /gifs/trending`; on typing → debounced `GET /gifs/search?q=`.
- [ ] Grid off `previewUrl`; paginate via `next` → `page`. Show "Powered by KLIPY".
- [ ] Send picked GIF as `media: [{ kind: "gif", remoteUrl: <result.url> }]` — no presign.
- [ ] Render returned `media[].type === "gif"` like an image; hide the GIF button on `503`.
- [ ] Handle `502` (provider down) gracefully.
