Skip to main content
Version: 1.0

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.

keyglyphkeyglyphkeyglyph
THUMBS_UP👍SPARKLESTHINKING🤔
HEART❤️CHECKSAD😢
LAUGH😂IDEA💡PRAY🙏
SMILE😊BOOK📚WAVE👋
WOW😮BRAIN🧠HANDSHAKE🤝
CLAP👏TROPHY🏆
PARTY🎉TARGET🎯
FIRE🔥EYES👀
STARHUNDRED💯
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 HEARTclears it
  • you already reacted LAUGHchanges to HEART

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.

  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

codewhen
400emoji not in the fixed set; or reacting to a deleted message
401missing / invalid token
403you're not an active participant of the conversation
404message not found
429too 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

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:

{
"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).

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" }
]
}
  • 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

codewhen
400search with empty q; or send with both storageKey and remoteUrl; or remoteUrl with kindgif
401missing / invalid token
403remoteUrl host not allowed / not https (only provider URLs from the search proxy are accepted)
429too many search/trending calls (60 / 60s / user)
502GIF provider unavailable / timed out — show "GIFs unavailable, try again"
503GIF 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 nextpage. 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.