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) andmobile-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
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 toHEART
Response — the message's reaction aggregate for you (server is source of truth; just replace your local array with this):
{
"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.
{
"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:
// 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)
- On tap, apply the toggle locally (add/replace/clear the chip) immediately.
- Fire the PUT.
- On success, replace with
data.reactions(authoritative). - 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:
- Discover — search / default feed via our server-side proxy (provider key stays server-side; you never call the GIF provider directly).
- 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
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 onsearch(400 if empty). Not used bytrending.limit— page size, capped server-side at 20.page— 1-based; omit for the first page.
Response — a normalized, provider-agnostic list:
{
"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 theurl(full). - Paginate by passing
nextback aspage(infinite scroll). - Debounce search input (~300 ms). Load
trendingwhen the picker opens with an empty query. - Attribution required: show "Powered by KLIPY" in the picker (provider ToS).
Send a picked GIF
Send inline as media with remoteUrl (the result's url) — not
storageKey, no presign:
POST /api/chat/conversations/:id/messages
{
"clientMessageId": "<uuid>",
"media": [
{ "kind": "gif", "remoteUrl": "https://static.klipy.com/.../m1yDUjBO.gif" }
]
}
remoteUrlis gif-only and mutually exclusive withstorageKey.- Server validates the host (must be the GIF provider, https) and stores it; the
message comes back (and rides
new_message) withmedia: [{ type: "gif", url, ... }]exactly like any other attachment — render it as an image (<img>/ animated). contentoptional (gif-only message is fine); everything else about the send is identical tomobile-chat-media-guide.md.
Uploaded GIFs (a
.giffile the user picks from their gallery) still go through the normal presign → PUT → send flow withkind: "gif"+storageKey.remoteUrlis 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.reactionson success. - Render
message.reactions[]chips; highlight wheremine. - Handle
message_reactionsocket: update counts, preserve your ownmine; drop the event if the message isn't cached yet.
GIFs
- Open picker →
GET /gifs/trending; on typing → debouncedGET /gifs/search?q=. - Grid off
previewUrl; paginate vianext→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 on503. - Handle
502(provider down) gracefully.