From 470b60ed8c427b5a6e69129f04e4ae6a01363766 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 15:11:59 -0400 Subject: [PATCH 01/58] test: sync chat-reactions fixtures from flipcash-client-orchestrator#17 --- .../chat/src/test/resources/reactions.json | 1766 +++++++++++++++++ libs/emojis/src/test/resources/reactions.json | 1766 +++++++++++++++++ 2 files changed, 3532 insertions(+) create mode 100644 apps/flipcash/shared/chat/src/test/resources/reactions.json create mode 100644 libs/emojis/src/test/resources/reactions.json diff --git a/apps/flipcash/shared/chat/src/test/resources/reactions.json b/apps/flipcash/shared/chat/src/test/resources/reactions.json new file mode 100644 index 0000000000..b8b86ea67d --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/resources/reactions.json @@ -0,0 +1,1766 @@ +{ + "defaults": [ + "❤️", + "👍", + "😂", + "😮", + "😢", + "🔥" + ], + "merge": [ + { + "name": "summary-renders", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 2, + "self": false, + "version": 4 + }, + { + "emoji": "🔥", + "count": 1, + "self": true, + "version": 1 + } + ] + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 2, + "selfReacted": false, + "pending": false + }, + { + "emoji": "🔥", + "count": 1, + "selfReacted": true, + "pending": false + } + ], + "calls": [], + "errors": [] + }, + "note": "A summary alone. Counts as given; self from self_reactor." + }, + { + "name": "stale-update-dropped", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 3, + "self": false, + "version": 7 + } + ] + }, + { + "op": "update", + "emoji": "👍", + "actor": "ana", + "action": "REMOVED", + "count": 2, + "version": 6 + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 3, + "selfReacted": false, + "pending": false + } + ], + "calls": [], + "errors": [] + }, + "note": "Version 6 is below the held 7, so it is an update the summary already folded in." + }, + { + "name": "equal-version-dropped", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 3, + "self": false, + "version": 7 + } + ] + }, + { + "op": "update", + "emoji": "👍", + "actor": "ana", + "action": "REMOVED", + "count": 2, + "version": 7 + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 3, + "selfReacted": false, + "pending": false + } + ], + "calls": [], + "errors": [] + }, + "note": "Accept strictly greater. The same version is the same state." + }, + { + "name": "newer-update-applied", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 3, + "self": false, + "version": 7 + } + ] + }, + { + "op": "update", + "emoji": "👍", + "actor": "ana", + "action": "ADDED", + "count": 4, + "version": 8 + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 4, + "selfReacted": false, + "pending": false + } + ], + "calls": [], + "errors": [] + }, + "note": "Count comes from the update, not from adding one: an update is state, not a delta." + }, + { + "name": "update-to-zero-removes-pill", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 3 + } + ] + }, + { + "op": "update", + "emoji": "👍", + "actor": "ana", + "action": "REMOVED", + "count": 0, + "version": 4 + } + ], + "expect": { + "pills": [], + "calls": [], + "errors": [] + }, + "note": "Zero count, no pill." + }, + { + "name": "tombstone-rejects-late-add", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 3 + } + ] + }, + { + "op": "update", + "emoji": "👍", + "actor": "ana", + "action": "REMOVED", + "count": 0, + "version": 4 + }, + { + "op": "update", + "emoji": "👍", + "actor": "ana", + "action": "ADDED", + "count": 1, + "version": 3 + } + ], + "expect": { + "pills": [], + "calls": [], + "errors": [] + }, + "note": "The server keeps an emptied emoji's version, so the client must too. Without the tombstone the late ADDED at 3 would resurrect the pill." + }, + { + "name": "readd-after-empty", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 3 + } + ] + }, + { + "op": "update", + "emoji": "👍", + "actor": "ana", + "action": "REMOVED", + "count": 0, + "version": 4 + }, + { + "op": "update", + "emoji": "👍", + "actor": "ben", + "action": "ADDED", + "count": 1, + "version": 5 + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 1, + "selfReacted": false, + "pending": false + } + ], + "calls": [], + "errors": [] + }, + "note": "A re-add comes above the tombstone and is accepted." + }, + { + "name": "new-emoji-from-stream", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 1 + } + ] + }, + { + "op": "update", + "emoji": "🎉", + "actor": "ana", + "action": "ADDED", + "count": 1, + "version": 1 + } + ], + "expect": { + "pills": [ + { + "emoji": "🎉", + "count": 1, + "selfReacted": false, + "pending": false + }, + { + "emoji": "👍", + "count": 1, + "selfReacted": false, + "pending": false + } + ], + "calls": [], + "errors": [] + }, + "note": "An emoji we have never held has no watermark; any version is accepted. Equal counts fall back to UTF-8 byte order: 🎉 (F0 9F 8E 89) before 👍 (F0 9F 91 8D)." + }, + { + "name": "own-update-from-another-device", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 1 + } + ] + }, + { + "op": "update", + "emoji": "👍", + "actor": "me", + "action": "ADDED", + "count": 2, + "version": 2 + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 2, + "selfReacted": true, + "pending": false + } + ], + "calls": [], + "errors": [] + }, + "note": "Our own reaction made on another device arrives on the stream and highlights the pill." + }, + { + "name": "tap-adds-optimistically", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 1 + } + ] + }, + { + "op": "tap", + "emoji": "👍" + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 2, + "selfReacted": true, + "pending": true + } + ], + "calls": [ + { + "op": "add", + "emoji": "👍" + } + ], + "errors": [] + }, + "note": "The pill changes before the server answers, and is marked pending." + }, + { + "name": "tap-new-emoji", + "self": "me", + "steps": [ + { + "op": "tap", + "emoji": "🔥" + } + ], + "expect": { + "pills": [ + { + "emoji": "🔥", + "count": 1, + "selfReacted": true, + "pending": true + } + ], + "calls": [ + { + "op": "add", + "emoji": "🔥" + } + ], + "errors": [] + }, + "note": "Tapping an emoji the message does not have yet, from the strip or picker." + }, + { + "name": "tap-confirmed", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 1 + } + ] + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "respond", + "emoji": "👍", + "result": "OK", + "reaction": { + "count": 2, + "self": true, + "version": 2 + } + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 2, + "selfReacted": true, + "pending": false + } + ], + "calls": [ + { + "op": "add", + "emoji": "👍" + } + ], + "errors": [] + }, + "note": "The response's EmojiReaction replaces the pending state." + }, + { + "name": "tap-removes", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 2, + "self": true, + "version": 5 + } + ] + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "respond", + "emoji": "👍", + "result": "OK", + "reaction": { + "count": 1, + "self": false, + "version": 6 + } + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 1, + "selfReacted": false, + "pending": false + } + ], + "calls": [ + { + "op": "remove", + "emoji": "👍" + } + ], + "errors": [] + }, + "note": "Tapping a highlighted pill removes." + }, + { + "name": "remove-last-reaction", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": true, + "version": 5 + } + ] + }, + { + "op": "tap", + "emoji": "👍" + } + ], + "expect": { + "pills": [], + "calls": [ + { + "op": "remove", + "emoji": "👍" + } + ], + "errors": [] + }, + "note": "Removing the only reaction hides the pill immediately, before the response." + }, + { + "name": "stream-during-pending-keeps-highlight", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 1 + } + ] + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "update", + "emoji": "👍", + "actor": "ana", + "action": "ADDED", + "count": 2, + "version": 2 + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 3, + "selfReacted": true, + "pending": true + } + ], + "calls": [ + { + "op": "add", + "emoji": "👍" + } + ], + "errors": [] + }, + "note": "Someone else's reaction lands while ours is on the wire: their count shows, our highlight stays." + }, + { + "name": "own-echo-before-response", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 1 + } + ] + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "update", + "emoji": "👍", + "actor": "me", + "action": "ADDED", + "count": 2, + "version": 2 + }, + { + "op": "respond", + "emoji": "👍", + "result": "OK", + "reaction": { + "count": 2, + "self": true, + "version": 2 + } + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 2, + "selfReacted": true, + "pending": false + } + ], + "calls": [ + { + "op": "add", + "emoji": "👍" + } + ], + "errors": [] + }, + "note": "The stream can deliver our own add before the RPC returns. It must not count us twice (3), and the response at the same version is then a no-op." + }, + { + "name": "coalesce-on-off-on", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 1 + } + ] + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "tap", + "emoji": "👍" + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 2, + "selfReacted": true, + "pending": true + } + ], + "calls": [ + { + "op": "add", + "emoji": "👍" + } + ], + "errors": [] + }, + "note": "Three taps while the first add is in flight: one call. The later taps only move the displayed state." + }, + { + "name": "coalesce-on-off-then-settle", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 1 + } + ] + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "respond", + "emoji": "👍", + "result": "OK", + "reaction": { + "count": 2, + "self": true, + "version": 2 + } + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 1, + "selfReacted": false, + "pending": true + } + ], + "calls": [ + { + "op": "add", + "emoji": "👍" + }, + { + "op": "remove", + "emoji": "👍" + } + ], + "errors": [] + }, + "note": "On, then off, while the add is in flight. The add lands; the final state (off) differs from the server's (on), so a remove follows, and the pill shows the off state as pending until it returns." + }, + { + "name": "coalesce-on-off-on-settle", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 1 + } + ] + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "respond", + "emoji": "👍", + "result": "OK", + "reaction": { + "count": 2, + "self": true, + "version": 2 + } + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 2, + "selfReacted": true, + "pending": false + } + ], + "calls": [ + { + "op": "add", + "emoji": "👍" + } + ], + "errors": [] + }, + "note": "On, off, on. The add lands and the final state already matches, so nothing more is sent." + }, + { + "name": "network-failure-rolls-back", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 1 + } + ] + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "respond", + "emoji": "👍", + "result": "NETWORK" + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 1, + "selfReacted": false, + "pending": false + } + ], + "calls": [ + { + "op": "add", + "emoji": "👍" + } + ], + "errors": [ + "reactionFailed" + ] + }, + "note": "Back to the confirmed state, with the generic error." + }, + { + "name": "denied-rolls-back", + "self": "me", + "steps": [ + { + "op": "tap", + "emoji": "🫩" + }, + { + "op": "respond", + "emoji": "🫩", + "result": "DENIED" + } + ], + "expect": { + "pills": [], + "calls": [ + { + "op": "add", + "emoji": "🫩" + } + ], + "errors": [ + "reactionFailed" + ] + }, + "note": "DENIED is shown like a network failure; the catalog should make it rare." + }, + { + "name": "too-many-types", + "self": "me", + "steps": [ + { + "op": "tap", + "emoji": "🦖" + }, + { + "op": "respond", + "emoji": "🦖", + "result": "TOO_MANY_REACTION_TYPES" + } + ], + "expect": { + "pills": [], + "calls": [ + { + "op": "add", + "emoji": "🦖" + } + ], + "errors": [ + "tooManyReactionTypes" + ] + }, + "note": "The 21st distinct emoji on a message." + }, + { + "name": "message-not-found-is-silent", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 1 + } + ] + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "respond", + "emoji": "👍", + "result": "MESSAGE_NOT_FOUND" + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 1, + "selfReacted": false, + "pending": false + } + ], + "calls": [ + { + "op": "add", + "emoji": "👍" + } + ], + "errors": [] + }, + "note": "The message was deleted; the stream is removing it, so no error is shown." + }, + { + "name": "cannot-react-is-silent", + "self": "me", + "steps": [ + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "respond", + "emoji": "👍", + "result": "CANNOT_REACT" + } + ], + "expect": { + "pills": [], + "calls": [ + { + "op": "add", + "emoji": "👍" + } + ], + "errors": [] + }, + "note": "The strip should not have been offered. Roll back quietly." + }, + { + "name": "failure-drops-coalesced-taps", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 1 + } + ] + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "respond", + "emoji": "👍", + "result": "NETWORK" + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 1, + "selfReacted": false, + "pending": false + } + ], + "calls": [ + { + "op": "add", + "emoji": "👍" + } + ], + "errors": [ + "reactionFailed" + ] + }, + "note": "Nothing is queued: a failure discards the taps made while it was in flight too." + }, + { + "name": "summary-omits-emptied-emoji", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 1 + }, + { + "emoji": "🔥", + "count": 2, + "self": false, + "version": 3 + } + ] + }, + { + "op": "summary", + "reactions": [ + { + "emoji": "🔥", + "count": 2, + "self": false, + "version": 3 + } + ] + } + ], + "expect": { + "pills": [ + { + "emoji": "🔥", + "count": 2, + "selfReacted": false, + "pending": false + } + ], + "calls": [], + "errors": [] + }, + "note": "A later summary without 👍 means it emptied." + }, + { + "name": "summary-keeps-pending-emoji", + "self": "me", + "steps": [ + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "summary", + "reactions": [ + { + "emoji": "🔥", + "count": 1, + "self": false, + "version": 1 + } + ] + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 1, + "selfReacted": true, + "pending": true + }, + { + "emoji": "🔥", + "count": 1, + "selfReacted": false, + "pending": false + } + ], + "calls": [ + { + "op": "add", + "emoji": "👍" + } + ], + "errors": [] + }, + "note": "A summary fetched before our add landed must not wipe the pending tap." + }, + { + "name": "reactions-are-per-emoji", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 9 + }, + { + "emoji": "🔥", + "count": 1, + "self": false, + "version": 2 + } + ] + }, + { + "op": "update", + "emoji": "🔥", + "actor": "ana", + "action": "ADDED", + "count": 2, + "version": 3 + } + ], + "expect": { + "pills": [ + { + "emoji": "🔥", + "count": 2, + "selfReacted": false, + "pending": false + }, + { + "emoji": "👍", + "count": 1, + "selfReacted": false, + "pending": false + } + ], + "calls": [], + "errors": [] + }, + "note": "Versions are per (message, emoji). 🔥 at 3 is fresh even though 👍 is at 9." + } + ], + "order": [ + { + "name": "count-descending", + "pills": [ + { + "emoji": "👍", + "count": 1, + "boostTotal": 0 + }, + { + "emoji": "🔥", + "count": 3, + "boostTotal": 0 + }, + { + "emoji": "😂", + "count": 2, + "boostTotal": 0 + } + ], + "expect": [ + "🔥", + "😂", + "👍" + ], + "note": "Phase 1: every boost total is 0, so count decides." + }, + { + "name": "boost-before-count", + "pills": [ + { + "emoji": "👍", + "count": 5, + "boostTotal": 0 + }, + { + "emoji": "💯", + "count": 2, + "boostTotal": 1000 + }, + { + "emoji": "👎", + "count": 1, + "boostTotal": 500 + } + ], + "expect": [ + "💯", + "👎", + "👍" + ], + "note": "Phase 2: any boost outranks any count. The unit is opaque here, only its order matters." + }, + { + "name": "tie-on-utf8-bytes", + "pills": [ + { + "emoji": "😂", + "count": 1, + "boostTotal": 0 + }, + { + "emoji": "👍", + "count": 1, + "boostTotal": 0 + }, + { + "emoji": "❤️", + "count": 1, + "boostTotal": 0 + } + ], + "expect": [ + "❤️", + "👍", + "😂" + ], + "note": "Ties fall back to the server's order, UTF-8 byte order. ❤️ starts E2, the others F0." + }, + { + "name": "tie-utf8-not-utf16", + "pills": [ + { + "emoji": "☝🏻", + "count": 1, + "boostTotal": 0 + }, + { + "emoji": "☝️", + "count": 1, + "boostTotal": 0 + } + ], + "expect": [ + "☝️", + "☝🏻" + ], + "note": "The case that separates byte order from a platform's default string compare. Both start U+261D. Next, ☝️ has U+FE0F (UTF-8 EF B8 8F) and ☝🏻 has U+1F3FB (F0 9F 8F BB): EF < F0 in UTF-8, but in UTF-16 the surrogate D83C < FE0F, which would flip them." + } + ], + "strip": [ + { + "name": "no-reactions", + "recents": [ + "😂", + "❤️", + "🔥", + "👍", + "😭", + "✨" + ], + "selfReactions": [], + "expect": [ + { + "emoji": "😂", + "highlighted": false + }, + { + "emoji": "❤️", + "highlighted": false + }, + { + "emoji": "🔥", + "highlighted": false + }, + { + "emoji": "👍", + "highlighted": false + }, + { + "emoji": "😭", + "highlighted": false + }, + { + "emoji": "✨", + "highlighted": false + } + ], + "note": "Recents, in rank order." + }, + { + "name": "reacted-with-a-recent", + "recents": [ + "😂", + "❤️", + "🔥", + "👍", + "😭", + "✨" + ], + "selfReactions": [ + { + "emoji": "🔥", + "reactedAt": 100 + } + ], + "expect": [ + { + "emoji": "😂", + "highlighted": false + }, + { + "emoji": "❤️", + "highlighted": false + }, + { + "emoji": "🔥", + "highlighted": true + }, + { + "emoji": "👍", + "highlighted": false + }, + { + "emoji": "😭", + "highlighted": false + }, + { + "emoji": "✨", + "highlighted": false + } + ], + "note": "A reaction already in the recents is highlighted in place, not moved." + }, + { + "name": "reacted-outside-recents", + "recents": [ + "😂", + "❤️", + "🔥", + "👍", + "😭", + "✨" + ], + "selfReactions": [ + { + "emoji": "💥", + "reactedAt": 100 + } + ], + "expect": [ + { + "emoji": "😂", + "highlighted": false + }, + { + "emoji": "❤️", + "highlighted": false + }, + { + "emoji": "🔥", + "highlighted": false + }, + { + "emoji": "👍", + "highlighted": false + }, + { + "emoji": "😭", + "highlighted": false + }, + { + "emoji": "✨", + "highlighted": false + }, + { + "emoji": "💥", + "highlighted": true + } + ], + "note": "After the six, next to +. The WhatsApp screenshot in the design review." + }, + { + "name": "several-outside-newest-first", + "recents": [ + "😂", + "❤️", + "🔥", + "👍", + "😭", + "✨" + ], + "selfReactions": [ + { + "emoji": "💥", + "reactedAt": 100 + }, + { + "emoji": "🦖", + "reactedAt": 300 + }, + { + "emoji": "🫠", + "reactedAt": 200 + } + ], + "expect": [ + { + "emoji": "😂", + "highlighted": false + }, + { + "emoji": "❤️", + "highlighted": false + }, + { + "emoji": "🔥", + "highlighted": false + }, + { + "emoji": "👍", + "highlighted": false + }, + { + "emoji": "😭", + "highlighted": false + }, + { + "emoji": "✨", + "highlighted": false + }, + { + "emoji": "🦖", + "highlighted": true + }, + { + "emoji": "🫠", + "highlighted": true + }, + { + "emoji": "💥", + "highlighted": true + } + ], + "note": "Newest reaction first, so the one you just made is the one visible before scrolling." + }, + { + "name": "mixed", + "recents": [ + "😂", + "❤️", + "🔥", + "👍", + "😭", + "✨" + ], + "selfReactions": [ + { + "emoji": "❤️", + "reactedAt": 50 + }, + { + "emoji": "🦖", + "reactedAt": 60 + } + ], + "expect": [ + { + "emoji": "😂", + "highlighted": false + }, + { + "emoji": "❤️", + "highlighted": true + }, + { + "emoji": "🔥", + "highlighted": false + }, + { + "emoji": "👍", + "highlighted": false + }, + { + "emoji": "😭", + "highlighted": false + }, + { + "emoji": "✨", + "highlighted": false + }, + { + "emoji": "🦖", + "highlighted": true + } + ], + "note": "Only the reaction outside the recents is appended." + } + ], + "recents": [ + { + "name": "empty-pads-defaults", + "uses": [], + "undrawable": [], + "limit": 6, + "expect": [ + "❤️", + "👍", + "😂", + "😮", + "😢", + "🔥" + ], + "note": "Nothing used yet: the defaults from node 10286:2573, in their order." + }, + { + "name": "frequency-first", + "uses": [ + { + "emoji": "🦖", + "usedAt": 1 + }, + { + "emoji": "🦖", + "usedAt": 2 + }, + { + "emoji": "🦖", + "usedAt": 3 + }, + { + "emoji": "🫠", + "usedAt": 4 + } + ], + "undrawable": [], + "limit": 6, + "expect": [ + "🦖", + "🫠", + "❤️", + "👍", + "😂", + "😮" + ], + "note": "Most used first, then padding with the defaults." + }, + { + "name": "recency-breaks-ties", + "uses": [ + { + "emoji": "🦖", + "usedAt": 1 + }, + { + "emoji": "🫠", + "usedAt": 2 + }, + { + "emoji": "🎉", + "usedAt": 3 + } + ], + "undrawable": [], + "limit": 6, + "expect": [ + "🎉", + "🫠", + "🦖", + "❤️", + "👍", + "😂" + ], + "note": "Equal counts: most recently used first." + }, + { + "name": "padding-skips-used-defaults", + "uses": [ + { + "emoji": "👍", + "usedAt": 1 + }, + { + "emoji": "👍", + "usedAt": 2 + } + ], + "undrawable": [], + "limit": 6, + "expect": [ + "👍", + "❤️", + "😂", + "😮", + "😢", + "🔥" + ], + "note": "A default already ranked is not repeated." + }, + { + "name": "undrawable-skipped", + "uses": [ + { + "emoji": "🫩", + "usedAt": 1 + }, + { + "emoji": "🫩", + "usedAt": 2 + }, + { + "emoji": "🦖", + "usedAt": 3 + } + ], + "undrawable": [ + "🫩" + ], + "limit": 6, + "expect": [ + "🦖", + "❤️", + "👍", + "😂", + "😮", + "😢" + ], + "note": "Emoji this device cannot draw are not offered, even if used on another OS version." + }, + { + "name": "picker-row-of-seven", + "uses": [], + "undrawable": [], + "limit": 7, + "expect": [ + "❤️", + "👍", + "😂", + "😮", + "😢", + "🔥" + ], + "note": "The picker asks for 7 and there are 6 defaults, so an unused picker shows 6. Not padded further: the row is not worth an invented seventh default." + }, + { + "name": "limit-truncates", + "uses": [ + { + "emoji": "🦖", + "usedAt": 0 + }, + { + "emoji": "🫠", + "usedAt": 1 + }, + { + "emoji": "🎉", + "usedAt": 2 + }, + { + "emoji": "💥", + "usedAt": 3 + }, + { + "emoji": "✨", + "usedAt": 4 + }, + { + "emoji": "😭", + "usedAt": 5 + }, + { + "emoji": "🥺", + "usedAt": 6 + }, + { + "emoji": "🙏", + "usedAt": 7 + } + ], + "undrawable": [], + "limit": 6, + "expect": [ + "🙏", + "🥺", + "😭", + "✨", + "💥", + "🎉" + ], + "note": "All used once, so recency orders them and the oldest two drop." + } + ], + "drawability": [ + { + "emoji": "😀", + "drawable": true, + "note": "Emoji 1.0, single code point." + }, + { + "emoji": "👍🏽", + "drawable": true, + "note": "Base plus skin-tone modifier." + }, + { + "emoji": "🧑🏻‍🤝‍🧑🏿", + "drawable": true, + "note": "Mixed-skin-tone ZWJ sequence, Emoji 12.1. The case a glyph-count rule gets wrong on iOS." + }, + { + "emoji": "🐈‍🦄", + "drawable": false, + "note": "Not an RGI sequence, so no font has a ligature; it draws as two emoji." + }, + { + "emoji": "🇦🇦", + "drawable": false, + "note": "Regional indicators that are not a flag draw as two letters." + } + ] +} diff --git a/libs/emojis/src/test/resources/reactions.json b/libs/emojis/src/test/resources/reactions.json new file mode 100644 index 0000000000..b8b86ea67d --- /dev/null +++ b/libs/emojis/src/test/resources/reactions.json @@ -0,0 +1,1766 @@ +{ + "defaults": [ + "❤️", + "👍", + "😂", + "😮", + "😢", + "🔥" + ], + "merge": [ + { + "name": "summary-renders", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 2, + "self": false, + "version": 4 + }, + { + "emoji": "🔥", + "count": 1, + "self": true, + "version": 1 + } + ] + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 2, + "selfReacted": false, + "pending": false + }, + { + "emoji": "🔥", + "count": 1, + "selfReacted": true, + "pending": false + } + ], + "calls": [], + "errors": [] + }, + "note": "A summary alone. Counts as given; self from self_reactor." + }, + { + "name": "stale-update-dropped", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 3, + "self": false, + "version": 7 + } + ] + }, + { + "op": "update", + "emoji": "👍", + "actor": "ana", + "action": "REMOVED", + "count": 2, + "version": 6 + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 3, + "selfReacted": false, + "pending": false + } + ], + "calls": [], + "errors": [] + }, + "note": "Version 6 is below the held 7, so it is an update the summary already folded in." + }, + { + "name": "equal-version-dropped", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 3, + "self": false, + "version": 7 + } + ] + }, + { + "op": "update", + "emoji": "👍", + "actor": "ana", + "action": "REMOVED", + "count": 2, + "version": 7 + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 3, + "selfReacted": false, + "pending": false + } + ], + "calls": [], + "errors": [] + }, + "note": "Accept strictly greater. The same version is the same state." + }, + { + "name": "newer-update-applied", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 3, + "self": false, + "version": 7 + } + ] + }, + { + "op": "update", + "emoji": "👍", + "actor": "ana", + "action": "ADDED", + "count": 4, + "version": 8 + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 4, + "selfReacted": false, + "pending": false + } + ], + "calls": [], + "errors": [] + }, + "note": "Count comes from the update, not from adding one: an update is state, not a delta." + }, + { + "name": "update-to-zero-removes-pill", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 3 + } + ] + }, + { + "op": "update", + "emoji": "👍", + "actor": "ana", + "action": "REMOVED", + "count": 0, + "version": 4 + } + ], + "expect": { + "pills": [], + "calls": [], + "errors": [] + }, + "note": "Zero count, no pill." + }, + { + "name": "tombstone-rejects-late-add", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 3 + } + ] + }, + { + "op": "update", + "emoji": "👍", + "actor": "ana", + "action": "REMOVED", + "count": 0, + "version": 4 + }, + { + "op": "update", + "emoji": "👍", + "actor": "ana", + "action": "ADDED", + "count": 1, + "version": 3 + } + ], + "expect": { + "pills": [], + "calls": [], + "errors": [] + }, + "note": "The server keeps an emptied emoji's version, so the client must too. Without the tombstone the late ADDED at 3 would resurrect the pill." + }, + { + "name": "readd-after-empty", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 3 + } + ] + }, + { + "op": "update", + "emoji": "👍", + "actor": "ana", + "action": "REMOVED", + "count": 0, + "version": 4 + }, + { + "op": "update", + "emoji": "👍", + "actor": "ben", + "action": "ADDED", + "count": 1, + "version": 5 + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 1, + "selfReacted": false, + "pending": false + } + ], + "calls": [], + "errors": [] + }, + "note": "A re-add comes above the tombstone and is accepted." + }, + { + "name": "new-emoji-from-stream", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 1 + } + ] + }, + { + "op": "update", + "emoji": "🎉", + "actor": "ana", + "action": "ADDED", + "count": 1, + "version": 1 + } + ], + "expect": { + "pills": [ + { + "emoji": "🎉", + "count": 1, + "selfReacted": false, + "pending": false + }, + { + "emoji": "👍", + "count": 1, + "selfReacted": false, + "pending": false + } + ], + "calls": [], + "errors": [] + }, + "note": "An emoji we have never held has no watermark; any version is accepted. Equal counts fall back to UTF-8 byte order: 🎉 (F0 9F 8E 89) before 👍 (F0 9F 91 8D)." + }, + { + "name": "own-update-from-another-device", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 1 + } + ] + }, + { + "op": "update", + "emoji": "👍", + "actor": "me", + "action": "ADDED", + "count": 2, + "version": 2 + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 2, + "selfReacted": true, + "pending": false + } + ], + "calls": [], + "errors": [] + }, + "note": "Our own reaction made on another device arrives on the stream and highlights the pill." + }, + { + "name": "tap-adds-optimistically", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 1 + } + ] + }, + { + "op": "tap", + "emoji": "👍" + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 2, + "selfReacted": true, + "pending": true + } + ], + "calls": [ + { + "op": "add", + "emoji": "👍" + } + ], + "errors": [] + }, + "note": "The pill changes before the server answers, and is marked pending." + }, + { + "name": "tap-new-emoji", + "self": "me", + "steps": [ + { + "op": "tap", + "emoji": "🔥" + } + ], + "expect": { + "pills": [ + { + "emoji": "🔥", + "count": 1, + "selfReacted": true, + "pending": true + } + ], + "calls": [ + { + "op": "add", + "emoji": "🔥" + } + ], + "errors": [] + }, + "note": "Tapping an emoji the message does not have yet, from the strip or picker." + }, + { + "name": "tap-confirmed", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 1 + } + ] + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "respond", + "emoji": "👍", + "result": "OK", + "reaction": { + "count": 2, + "self": true, + "version": 2 + } + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 2, + "selfReacted": true, + "pending": false + } + ], + "calls": [ + { + "op": "add", + "emoji": "👍" + } + ], + "errors": [] + }, + "note": "The response's EmojiReaction replaces the pending state." + }, + { + "name": "tap-removes", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 2, + "self": true, + "version": 5 + } + ] + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "respond", + "emoji": "👍", + "result": "OK", + "reaction": { + "count": 1, + "self": false, + "version": 6 + } + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 1, + "selfReacted": false, + "pending": false + } + ], + "calls": [ + { + "op": "remove", + "emoji": "👍" + } + ], + "errors": [] + }, + "note": "Tapping a highlighted pill removes." + }, + { + "name": "remove-last-reaction", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": true, + "version": 5 + } + ] + }, + { + "op": "tap", + "emoji": "👍" + } + ], + "expect": { + "pills": [], + "calls": [ + { + "op": "remove", + "emoji": "👍" + } + ], + "errors": [] + }, + "note": "Removing the only reaction hides the pill immediately, before the response." + }, + { + "name": "stream-during-pending-keeps-highlight", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 1 + } + ] + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "update", + "emoji": "👍", + "actor": "ana", + "action": "ADDED", + "count": 2, + "version": 2 + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 3, + "selfReacted": true, + "pending": true + } + ], + "calls": [ + { + "op": "add", + "emoji": "👍" + } + ], + "errors": [] + }, + "note": "Someone else's reaction lands while ours is on the wire: their count shows, our highlight stays." + }, + { + "name": "own-echo-before-response", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 1 + } + ] + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "update", + "emoji": "👍", + "actor": "me", + "action": "ADDED", + "count": 2, + "version": 2 + }, + { + "op": "respond", + "emoji": "👍", + "result": "OK", + "reaction": { + "count": 2, + "self": true, + "version": 2 + } + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 2, + "selfReacted": true, + "pending": false + } + ], + "calls": [ + { + "op": "add", + "emoji": "👍" + } + ], + "errors": [] + }, + "note": "The stream can deliver our own add before the RPC returns. It must not count us twice (3), and the response at the same version is then a no-op." + }, + { + "name": "coalesce-on-off-on", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 1 + } + ] + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "tap", + "emoji": "👍" + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 2, + "selfReacted": true, + "pending": true + } + ], + "calls": [ + { + "op": "add", + "emoji": "👍" + } + ], + "errors": [] + }, + "note": "Three taps while the first add is in flight: one call. The later taps only move the displayed state." + }, + { + "name": "coalesce-on-off-then-settle", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 1 + } + ] + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "respond", + "emoji": "👍", + "result": "OK", + "reaction": { + "count": 2, + "self": true, + "version": 2 + } + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 1, + "selfReacted": false, + "pending": true + } + ], + "calls": [ + { + "op": "add", + "emoji": "👍" + }, + { + "op": "remove", + "emoji": "👍" + } + ], + "errors": [] + }, + "note": "On, then off, while the add is in flight. The add lands; the final state (off) differs from the server's (on), so a remove follows, and the pill shows the off state as pending until it returns." + }, + { + "name": "coalesce-on-off-on-settle", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 1 + } + ] + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "respond", + "emoji": "👍", + "result": "OK", + "reaction": { + "count": 2, + "self": true, + "version": 2 + } + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 2, + "selfReacted": true, + "pending": false + } + ], + "calls": [ + { + "op": "add", + "emoji": "👍" + } + ], + "errors": [] + }, + "note": "On, off, on. The add lands and the final state already matches, so nothing more is sent." + }, + { + "name": "network-failure-rolls-back", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 1 + } + ] + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "respond", + "emoji": "👍", + "result": "NETWORK" + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 1, + "selfReacted": false, + "pending": false + } + ], + "calls": [ + { + "op": "add", + "emoji": "👍" + } + ], + "errors": [ + "reactionFailed" + ] + }, + "note": "Back to the confirmed state, with the generic error." + }, + { + "name": "denied-rolls-back", + "self": "me", + "steps": [ + { + "op": "tap", + "emoji": "🫩" + }, + { + "op": "respond", + "emoji": "🫩", + "result": "DENIED" + } + ], + "expect": { + "pills": [], + "calls": [ + { + "op": "add", + "emoji": "🫩" + } + ], + "errors": [ + "reactionFailed" + ] + }, + "note": "DENIED is shown like a network failure; the catalog should make it rare." + }, + { + "name": "too-many-types", + "self": "me", + "steps": [ + { + "op": "tap", + "emoji": "🦖" + }, + { + "op": "respond", + "emoji": "🦖", + "result": "TOO_MANY_REACTION_TYPES" + } + ], + "expect": { + "pills": [], + "calls": [ + { + "op": "add", + "emoji": "🦖" + } + ], + "errors": [ + "tooManyReactionTypes" + ] + }, + "note": "The 21st distinct emoji on a message." + }, + { + "name": "message-not-found-is-silent", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 1 + } + ] + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "respond", + "emoji": "👍", + "result": "MESSAGE_NOT_FOUND" + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 1, + "selfReacted": false, + "pending": false + } + ], + "calls": [ + { + "op": "add", + "emoji": "👍" + } + ], + "errors": [] + }, + "note": "The message was deleted; the stream is removing it, so no error is shown." + }, + { + "name": "cannot-react-is-silent", + "self": "me", + "steps": [ + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "respond", + "emoji": "👍", + "result": "CANNOT_REACT" + } + ], + "expect": { + "pills": [], + "calls": [ + { + "op": "add", + "emoji": "👍" + } + ], + "errors": [] + }, + "note": "The strip should not have been offered. Roll back quietly." + }, + { + "name": "failure-drops-coalesced-taps", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 1 + } + ] + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "respond", + "emoji": "👍", + "result": "NETWORK" + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 1, + "selfReacted": false, + "pending": false + } + ], + "calls": [ + { + "op": "add", + "emoji": "👍" + } + ], + "errors": [ + "reactionFailed" + ] + }, + "note": "Nothing is queued: a failure discards the taps made while it was in flight too." + }, + { + "name": "summary-omits-emptied-emoji", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 1 + }, + { + "emoji": "🔥", + "count": 2, + "self": false, + "version": 3 + } + ] + }, + { + "op": "summary", + "reactions": [ + { + "emoji": "🔥", + "count": 2, + "self": false, + "version": 3 + } + ] + } + ], + "expect": { + "pills": [ + { + "emoji": "🔥", + "count": 2, + "selfReacted": false, + "pending": false + } + ], + "calls": [], + "errors": [] + }, + "note": "A later summary without 👍 means it emptied." + }, + { + "name": "summary-keeps-pending-emoji", + "self": "me", + "steps": [ + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "summary", + "reactions": [ + { + "emoji": "🔥", + "count": 1, + "self": false, + "version": 1 + } + ] + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 1, + "selfReacted": true, + "pending": true + }, + { + "emoji": "🔥", + "count": 1, + "selfReacted": false, + "pending": false + } + ], + "calls": [ + { + "op": "add", + "emoji": "👍" + } + ], + "errors": [] + }, + "note": "A summary fetched before our add landed must not wipe the pending tap." + }, + { + "name": "reactions-are-per-emoji", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 9 + }, + { + "emoji": "🔥", + "count": 1, + "self": false, + "version": 2 + } + ] + }, + { + "op": "update", + "emoji": "🔥", + "actor": "ana", + "action": "ADDED", + "count": 2, + "version": 3 + } + ], + "expect": { + "pills": [ + { + "emoji": "🔥", + "count": 2, + "selfReacted": false, + "pending": false + }, + { + "emoji": "👍", + "count": 1, + "selfReacted": false, + "pending": false + } + ], + "calls": [], + "errors": [] + }, + "note": "Versions are per (message, emoji). 🔥 at 3 is fresh even though 👍 is at 9." + } + ], + "order": [ + { + "name": "count-descending", + "pills": [ + { + "emoji": "👍", + "count": 1, + "boostTotal": 0 + }, + { + "emoji": "🔥", + "count": 3, + "boostTotal": 0 + }, + { + "emoji": "😂", + "count": 2, + "boostTotal": 0 + } + ], + "expect": [ + "🔥", + "😂", + "👍" + ], + "note": "Phase 1: every boost total is 0, so count decides." + }, + { + "name": "boost-before-count", + "pills": [ + { + "emoji": "👍", + "count": 5, + "boostTotal": 0 + }, + { + "emoji": "💯", + "count": 2, + "boostTotal": 1000 + }, + { + "emoji": "👎", + "count": 1, + "boostTotal": 500 + } + ], + "expect": [ + "💯", + "👎", + "👍" + ], + "note": "Phase 2: any boost outranks any count. The unit is opaque here, only its order matters." + }, + { + "name": "tie-on-utf8-bytes", + "pills": [ + { + "emoji": "😂", + "count": 1, + "boostTotal": 0 + }, + { + "emoji": "👍", + "count": 1, + "boostTotal": 0 + }, + { + "emoji": "❤️", + "count": 1, + "boostTotal": 0 + } + ], + "expect": [ + "❤️", + "👍", + "😂" + ], + "note": "Ties fall back to the server's order, UTF-8 byte order. ❤️ starts E2, the others F0." + }, + { + "name": "tie-utf8-not-utf16", + "pills": [ + { + "emoji": "☝🏻", + "count": 1, + "boostTotal": 0 + }, + { + "emoji": "☝️", + "count": 1, + "boostTotal": 0 + } + ], + "expect": [ + "☝️", + "☝🏻" + ], + "note": "The case that separates byte order from a platform's default string compare. Both start U+261D. Next, ☝️ has U+FE0F (UTF-8 EF B8 8F) and ☝🏻 has U+1F3FB (F0 9F 8F BB): EF < F0 in UTF-8, but in UTF-16 the surrogate D83C < FE0F, which would flip them." + } + ], + "strip": [ + { + "name": "no-reactions", + "recents": [ + "😂", + "❤️", + "🔥", + "👍", + "😭", + "✨" + ], + "selfReactions": [], + "expect": [ + { + "emoji": "😂", + "highlighted": false + }, + { + "emoji": "❤️", + "highlighted": false + }, + { + "emoji": "🔥", + "highlighted": false + }, + { + "emoji": "👍", + "highlighted": false + }, + { + "emoji": "😭", + "highlighted": false + }, + { + "emoji": "✨", + "highlighted": false + } + ], + "note": "Recents, in rank order." + }, + { + "name": "reacted-with-a-recent", + "recents": [ + "😂", + "❤️", + "🔥", + "👍", + "😭", + "✨" + ], + "selfReactions": [ + { + "emoji": "🔥", + "reactedAt": 100 + } + ], + "expect": [ + { + "emoji": "😂", + "highlighted": false + }, + { + "emoji": "❤️", + "highlighted": false + }, + { + "emoji": "🔥", + "highlighted": true + }, + { + "emoji": "👍", + "highlighted": false + }, + { + "emoji": "😭", + "highlighted": false + }, + { + "emoji": "✨", + "highlighted": false + } + ], + "note": "A reaction already in the recents is highlighted in place, not moved." + }, + { + "name": "reacted-outside-recents", + "recents": [ + "😂", + "❤️", + "🔥", + "👍", + "😭", + "✨" + ], + "selfReactions": [ + { + "emoji": "💥", + "reactedAt": 100 + } + ], + "expect": [ + { + "emoji": "😂", + "highlighted": false + }, + { + "emoji": "❤️", + "highlighted": false + }, + { + "emoji": "🔥", + "highlighted": false + }, + { + "emoji": "👍", + "highlighted": false + }, + { + "emoji": "😭", + "highlighted": false + }, + { + "emoji": "✨", + "highlighted": false + }, + { + "emoji": "💥", + "highlighted": true + } + ], + "note": "After the six, next to +. The WhatsApp screenshot in the design review." + }, + { + "name": "several-outside-newest-first", + "recents": [ + "😂", + "❤️", + "🔥", + "👍", + "😭", + "✨" + ], + "selfReactions": [ + { + "emoji": "💥", + "reactedAt": 100 + }, + { + "emoji": "🦖", + "reactedAt": 300 + }, + { + "emoji": "🫠", + "reactedAt": 200 + } + ], + "expect": [ + { + "emoji": "😂", + "highlighted": false + }, + { + "emoji": "❤️", + "highlighted": false + }, + { + "emoji": "🔥", + "highlighted": false + }, + { + "emoji": "👍", + "highlighted": false + }, + { + "emoji": "😭", + "highlighted": false + }, + { + "emoji": "✨", + "highlighted": false + }, + { + "emoji": "🦖", + "highlighted": true + }, + { + "emoji": "🫠", + "highlighted": true + }, + { + "emoji": "💥", + "highlighted": true + } + ], + "note": "Newest reaction first, so the one you just made is the one visible before scrolling." + }, + { + "name": "mixed", + "recents": [ + "😂", + "❤️", + "🔥", + "👍", + "😭", + "✨" + ], + "selfReactions": [ + { + "emoji": "❤️", + "reactedAt": 50 + }, + { + "emoji": "🦖", + "reactedAt": 60 + } + ], + "expect": [ + { + "emoji": "😂", + "highlighted": false + }, + { + "emoji": "❤️", + "highlighted": true + }, + { + "emoji": "🔥", + "highlighted": false + }, + { + "emoji": "👍", + "highlighted": false + }, + { + "emoji": "😭", + "highlighted": false + }, + { + "emoji": "✨", + "highlighted": false + }, + { + "emoji": "🦖", + "highlighted": true + } + ], + "note": "Only the reaction outside the recents is appended." + } + ], + "recents": [ + { + "name": "empty-pads-defaults", + "uses": [], + "undrawable": [], + "limit": 6, + "expect": [ + "❤️", + "👍", + "😂", + "😮", + "😢", + "🔥" + ], + "note": "Nothing used yet: the defaults from node 10286:2573, in their order." + }, + { + "name": "frequency-first", + "uses": [ + { + "emoji": "🦖", + "usedAt": 1 + }, + { + "emoji": "🦖", + "usedAt": 2 + }, + { + "emoji": "🦖", + "usedAt": 3 + }, + { + "emoji": "🫠", + "usedAt": 4 + } + ], + "undrawable": [], + "limit": 6, + "expect": [ + "🦖", + "🫠", + "❤️", + "👍", + "😂", + "😮" + ], + "note": "Most used first, then padding with the defaults." + }, + { + "name": "recency-breaks-ties", + "uses": [ + { + "emoji": "🦖", + "usedAt": 1 + }, + { + "emoji": "🫠", + "usedAt": 2 + }, + { + "emoji": "🎉", + "usedAt": 3 + } + ], + "undrawable": [], + "limit": 6, + "expect": [ + "🎉", + "🫠", + "🦖", + "❤️", + "👍", + "😂" + ], + "note": "Equal counts: most recently used first." + }, + { + "name": "padding-skips-used-defaults", + "uses": [ + { + "emoji": "👍", + "usedAt": 1 + }, + { + "emoji": "👍", + "usedAt": 2 + } + ], + "undrawable": [], + "limit": 6, + "expect": [ + "👍", + "❤️", + "😂", + "😮", + "😢", + "🔥" + ], + "note": "A default already ranked is not repeated." + }, + { + "name": "undrawable-skipped", + "uses": [ + { + "emoji": "🫩", + "usedAt": 1 + }, + { + "emoji": "🫩", + "usedAt": 2 + }, + { + "emoji": "🦖", + "usedAt": 3 + } + ], + "undrawable": [ + "🫩" + ], + "limit": 6, + "expect": [ + "🦖", + "❤️", + "👍", + "😂", + "😮", + "😢" + ], + "note": "Emoji this device cannot draw are not offered, even if used on another OS version." + }, + { + "name": "picker-row-of-seven", + "uses": [], + "undrawable": [], + "limit": 7, + "expect": [ + "❤️", + "👍", + "😂", + "😮", + "😢", + "🔥" + ], + "note": "The picker asks for 7 and there are 6 defaults, so an unused picker shows 6. Not padded further: the row is not worth an invented seventh default." + }, + { + "name": "limit-truncates", + "uses": [ + { + "emoji": "🦖", + "usedAt": 0 + }, + { + "emoji": "🫠", + "usedAt": 1 + }, + { + "emoji": "🎉", + "usedAt": 2 + }, + { + "emoji": "💥", + "usedAt": 3 + }, + { + "emoji": "✨", + "usedAt": 4 + }, + { + "emoji": "😭", + "usedAt": 5 + }, + { + "emoji": "🥺", + "usedAt": 6 + }, + { + "emoji": "🙏", + "usedAt": 7 + } + ], + "undrawable": [], + "limit": 6, + "expect": [ + "🙏", + "🥺", + "😭", + "✨", + "💥", + "🎉" + ], + "note": "All used once, so recency orders them and the oldest two drop." + } + ], + "drawability": [ + { + "emoji": "😀", + "drawable": true, + "note": "Emoji 1.0, single code point." + }, + { + "emoji": "👍🏽", + "drawable": true, + "note": "Base plus skin-tone modifier." + }, + { + "emoji": "🧑🏻‍🤝‍🧑🏿", + "drawable": true, + "note": "Mixed-skin-tone ZWJ sequence, Emoji 12.1. The case a glyph-count rule gets wrong on iOS." + }, + { + "emoji": "🐈‍🦄", + "drawable": false, + "note": "Not an RGI sequence, so no font has a ligature; it draws as two emoji." + }, + { + "emoji": "🇦🇦", + "drawable": false, + "note": "Regional indicators that are not a flag draw as two letters." + } + ] +} From 39aff8ab041d6834f94695d0d5f65be816d13d75 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 15:11:59 -0400 Subject: [PATCH 02/58] refactor(chat): name the reaction version field version, as the proto does --- .../internal/delegates/EventStreamDelegate.kt | 8 ++++---- .../shared/chat/ChatCoordinatorEventsTest.kt | 16 ++++++++-------- .../persistence/converters/ChatTypeConverters.kt | 2 +- .../converters/ChatTypeConvertersTest.kt | 4 ++-- .../sources/mapper/chat/ChatEntityMapper.kt | 4 ++-- .../network/extensions/ProtobufToLocal.kt | 4 ++-- .../services/models/chat/EmojiReaction.kt | 2 +- .../services/models/chat/ReactionUpdate.kt | 2 +- .../controllers/ChatMessagingControllerTest.kt | 2 +- 9 files changed, 22 insertions(+), 22 deletions(-) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt index 6e99eef415..687eca2c64 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt @@ -60,7 +60,7 @@ import kotlin.time.Duration.Companion.seconds * gap is detected, a timed [getDelta][performDeltaSync] backfill is scheduled. * - **Reaction overlays** — merges [ReactionUpdate]s into an in-memory * `Map>` with last-writer-wins on - * `EmojiReaction.sequence`. + * `EmojiReaction.version`. * - **Typing indicators** — maintains per-chat `Set` in [ChatStateHolder]. * - **Eager balance update** — credits incoming cash messages to [TokenCoordinator] * before the server balance refresh arrives. @@ -474,7 +474,7 @@ class EventStreamDelegate @Inject constructor( val idx = existingReactions.indexOfFirst { it.emoji == update.emoji } if (idx >= 0) { val current = existingReactions[idx] - if (update.sequence <= current.sequence) return + if (update.version <= current.version) return val selfReactor = when { isSelfActor && update.action == ReactionUpdate.Action.ADDED -> Reactor(userId = update.actor, reactedAt = update.reactedAt, version = current.selfReactor?.version ?: 0) @@ -486,7 +486,7 @@ class EventStreamDelegate @Inject constructor( count = update.count, selfReactor = selfReactor, sampleReactors = current.sampleReactors, - sequence = update.sequence, + version = update.version, ) } else { val selfReactor = if (isSelfActor && update.action == ReactionUpdate.Action.ADDED) { @@ -500,7 +500,7 @@ class EventStreamDelegate @Inject constructor( count = update.count, selfReactor = selfReactor, sampleReactors = emptyList(), - sequence = update.sequence, + version = update.version, ) ) } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt index 5716d1025f..23da7d850a 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt @@ -302,7 +302,7 @@ class ChatCoordinatorEventsTest { actor = otherId, action = ReactionUpdate.Action.ADDED, count = 1, - sequence = 1, + version = 1, reactedAt = Instant.fromEpochSeconds(1000), ), ), @@ -337,7 +337,7 @@ class ChatCoordinatorEventsTest { actor = otherId, action = ReactionUpdate.Action.ADDED, count = 3, - sequence = 5, + version = 5, reactedAt = Instant.fromEpochSeconds(1000), ), ), @@ -355,7 +355,7 @@ class ChatCoordinatorEventsTest { actor = otherId, action = ReactionUpdate.Action.REMOVED, count = 1, - sequence = 2, // older than 5 + version = 2, // older than 5 reactedAt = Instant.fromEpochSeconds(500), ), ), @@ -367,7 +367,7 @@ class ChatCoordinatorEventsTest { assertNotNull(reactions) assertEquals(1, reactions.size) assertEquals(3L, reactions[0].count) // stayed at 3, stale update rejected - assertEquals(5L, reactions[0].sequence) + assertEquals(5L, reactions[0].version) } } @@ -386,7 +386,7 @@ class ChatCoordinatorEventsTest { actor = otherId, action = ReactionUpdate.Action.ADDED, count = 1, - sequence = 1, + version = 1, reactedAt = Instant.fromEpochSeconds(1000), ), ), @@ -404,7 +404,7 @@ class ChatCoordinatorEventsTest { actor = otherId, action = ReactionUpdate.Action.REMOVED, count = 0, - sequence = 2, + version = 2, reactedAt = Instant.fromEpochSeconds(2000), ), ), @@ -432,7 +432,7 @@ class ChatCoordinatorEventsTest { actor = otherId, action = ReactionUpdate.Action.ADDED, count = 2, - sequence = 1, + version = 1, reactedAt = Instant.fromEpochSeconds(1000), ), ReactionUpdate( @@ -441,7 +441,7 @@ class ChatCoordinatorEventsTest { actor = otherId, action = ReactionUpdate.Action.ADDED, count = 5, - sequence = 2, + version = 2, reactedAt = Instant.fromEpochSeconds(1000), ), ), diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt index adae839637..cf1b4ffc9a 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt @@ -259,7 +259,7 @@ data class EmojiReactionSerialized( val count: Long, val selfReactor: ReactorSerialized? = null, val sampleReactors: List, - val sequence: Long, + val version: Long, ) @Serializable diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt index a752a6b171..f6ce05d71b 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt @@ -24,14 +24,14 @@ class ChatTypeConvertersTest { ReactorSerialized(userIdHex = "aabb", reactedAtEpochSeconds = 1000L), ReactorSerialized(userIdHex = "ccdd", reactedAtEpochSeconds = 2000L), ), - sequence = 5, + version = 5, ), EmojiReactionSerialized( emoji = "\uD83D\uDC4D", count = 1, selfReactor = null, sampleReactors = emptyList(), - sequence = 3, + version = 3, ), ), ) diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt index f2b3bb2f10..32526442df 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt @@ -436,7 +436,7 @@ private fun EmojiReaction.toSerialized(): EmojiReactionSerialized = EmojiReactio count = count, selfReactor = selfReactor?.toSerialized(), sampleReactors = sampleReactors.map { it.toSerialized() }, - sequence = sequence, + version = version, ) private fun Reactor.toSerialized(): ReactorSerialized = ReactorSerialized( @@ -455,7 +455,7 @@ private fun EmojiReactionSerialized.toDomain(): EmojiReaction = EmojiReaction( count = count, selfReactor = selfReactor?.toDomain(), sampleReactors = sampleReactors.map { it.toDomain() }, - sequence = sequence, + version = version, ) private fun ReactorSerialized.toDomain(): Reactor = Reactor( diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt index 73353bca7a..b93ccbdbd2 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt @@ -266,7 +266,7 @@ internal fun MessagingModel.EmojiReaction.toEmojiReaction(): EmojiReaction { count = count, selfReactor = if (hasSelfReactor()) selfReactor.toReactor() else null, sampleReactors = sampleReactorsList.map { it.toReactor() }, - sequence = version, + version = version, ) } @@ -289,7 +289,7 @@ internal fun MessagingModel.ReactionUpdate.toReactionUpdate(): ReactionUpdate { else -> ReactionUpdate.Action.UNKNOWN }, count = count, - sequence = version, + version = version, reactedAt = Instant.fromEpochSeconds(reactedTs.seconds, reactedTs.nanos), ) } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/EmojiReaction.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/EmojiReaction.kt index cfba62d242..e8a4e39ab9 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/EmojiReaction.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/EmojiReaction.kt @@ -5,5 +5,5 @@ data class EmojiReaction( val count: Long, val selfReactor: Reactor?, val sampleReactors: List, - val sequence: Long, + val version: Long, ) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ReactionUpdate.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ReactionUpdate.kt index b99e96eb9f..f81524b896 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ReactionUpdate.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ReactionUpdate.kt @@ -9,7 +9,7 @@ data class ReactionUpdate( val actor: ID, val action: Action, val count: Long, - val sequence: Long, + val version: Long, val reactedAt: Instant, ) { enum class Action { diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatMessagingControllerTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatMessagingControllerTest.kt index 9a69a0681c..957b0f097a 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatMessagingControllerTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatMessagingControllerTest.kt @@ -61,7 +61,7 @@ class ChatMessagingControllerTest { count = count, selfReactor = null, sampleReactors = emptyList(), - sequence = 1, + version = 1, ) // region getMessage From 92431344d7750645869cc5b6bbb5be172a0c9bce Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 15:12:00 -0400 Subject: [PATCH 03/58] feat(chat): add the reaction reducer, pill order, strip, recents and catalog --- apps/flipcash/shared/chat/build.gradle.kts | 1 + .../shared/chat/reactions/ReactionOrdering.kt | 37 + .../shared/chat/reactions/ReactionPill.kt | 62 + .../shared/chat/reactions/ReactionState.kt | 238 + .../shared/chat/reactions/ReactionStrip.kt | 31 + .../chat/reactions/ReactionStripComposer.kt | 52 + .../reactions/ReactionStripComposerTest.kt | 100 + .../chat/reactions/ReactionVectorsTest.kt | 222 + libs/emojis/build.gradle.kts | 4 + .../libs/emojis/EmojiUsageController.kt | 7 +- .../libs/emojis/reactions/EmojiCatalog.kt | 76 + .../libs/emojis/reactions/EmojiDrawability.kt | 29 + .../libs/emojis/reactions/ReactionsModule.kt | 23 + .../libs/emojis/reactions/RecentReactions.kt | 60 + .../emojis/reactions/RecentReactionsStore.kt | 72 + .../src/main/resources/emoji_catalog.json | 3953 +++++++++++++++++ .../reactions/EmojiCatalogLoaderTest.kt | 52 + .../reactions/RecentReactionsVectorTest.kt | 96 + 18 files changed, 5113 insertions(+), 2 deletions(-) create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactionOrdering.kt create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactionPill.kt create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactionState.kt create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactionStrip.kt create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactionStripComposer.kt create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/reactions/ReactionStripComposerTest.kt create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/reactions/ReactionVectorsTest.kt create mode 100644 libs/emojis/src/main/kotlin/com/getcode/libs/emojis/reactions/EmojiCatalog.kt create mode 100644 libs/emojis/src/main/kotlin/com/getcode/libs/emojis/reactions/EmojiDrawability.kt create mode 100644 libs/emojis/src/main/kotlin/com/getcode/libs/emojis/reactions/ReactionsModule.kt create mode 100644 libs/emojis/src/main/kotlin/com/getcode/libs/emojis/reactions/RecentReactions.kt create mode 100644 libs/emojis/src/main/kotlin/com/getcode/libs/emojis/reactions/RecentReactionsStore.kt create mode 100644 libs/emojis/src/main/resources/emoji_catalog.json create mode 100644 libs/emojis/src/test/kotlin/com/getcode/libs/emojis/reactions/EmojiCatalogLoaderTest.kt create mode 100644 libs/emojis/src/test/kotlin/com/getcode/libs/emojis/reactions/RecentReactionsVectorTest.kt diff --git a/apps/flipcash/shared/chat/build.gradle.kts b/apps/flipcash/shared/chat/build.gradle.kts index a5c83155d4..fe2edf74dd 100644 --- a/apps/flipcash/shared/chat/build.gradle.kts +++ b/apps/flipcash/shared/chat/build.gradle.kts @@ -26,5 +26,6 @@ dependencies { testImplementation(testFixtures(project(":apps:flipcash:shared:analytics"))) implementation(project(":services:flipcash")) implementation(project(":libs:network:connectivity:public")) + implementation(project(":libs:emojis")) implementation(libs.androidx.lifecycle.process) } diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactionOrdering.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactionOrdering.kt new file mode 100644 index 0000000000..50369daee6 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactionOrdering.kt @@ -0,0 +1,37 @@ +package com.flipcash.shared.chat.reactions + +/** + * The order pills are shown in: boost total descending, then count descending, then the emoji's + * UTF-8 bytes ascending. + * + * The last tiebreak matches the server, which sorts summaries with Go's bytewise string compare + * (`flipcash2-server` `messaging/dynamodb/store.go:2277`). Kotlin's `String.compareTo` compares by + * UTF-16 code unit instead, which disagrees with byte order for some multi-scalar emoji, so it + * must not be used here. + */ +object ReactionOrdering { + + /** Returns pills-display order: negative when [lhs] is shown before [rhs]. */ + val comparator: Comparator = Comparator { lhs, rhs -> + val lhsBoost = lhs.boost?.total ?: 0L + val rhsBoost = rhs.boost?.total ?: 0L + if (lhsBoost != rhsBoost) return@Comparator rhsBoost.compareTo(lhsBoost) + if (lhs.count != rhs.count) return@Comparator rhs.count.compareTo(lhs.count) + compareUtf8Bytes(lhs.emoji, rhs.emoji) + } + + /** Returns [pills] in display order. */ + fun sorted(pills: Iterable): List = pills.sortedWith(comparator) + + /** Compares two emoji by their raw UTF-8 bytes, ascending, matching the server's order. */ + fun compareUtf8Bytes(lhs: String, rhs: String): Int { + val a = lhs.encodeToByteArray() + val b = rhs.encodeToByteArray() + val len = minOf(a.size, b.size) + for (i in 0 until len) { + val cmp = (a[i].toInt() and 0xFF) - (b[i].toInt() and 0xFF) + if (cmp != 0) return cmp + } + return a.size - b.size + } +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactionPill.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactionPill.kt new file mode 100644 index 0000000000..9ebe0c7bc7 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactionPill.kt @@ -0,0 +1,62 @@ +package com.flipcash.shared.chat.reactions + +import kotlin.time.Instant + +/** + * One emoji's displayed reaction on a message, with [count] and [selfReacted] already resolved + * from the pending tap over the confirmed server state. + */ +data class ReactionPill( + val emoji: String, + val count: Long, + val selfReacted: Boolean, + /** True while the user's last tap on this emoji has not been reconciled with the server. */ + val pending: Boolean, + val boost: ReactionBoost? = null, +) + +/** The paid boost on a pill, a phase-2 seam that nothing creates in phase 1. */ +data class ReactionBoost(val total: Long) + +/** An add or remove the caller must send to the server for one emoji. */ +data class ReactionCall(val op: Op, val emoji: String) { + enum class Op { ADD, REMOVE } +} + +/** Why an add or remove call failed, as far as the reaction merge needs to know. */ +enum class ReactionFailure { + NETWORK, + DENIED, + MESSAGE_NOT_FOUND, + CANNOT_REACT, + TOO_MANY_REACTION_TYPES; + + /** The error to show the user for this failure, or null when the rollback is silent. */ + val userError: ReactionError? + get() = when (this) { + NETWORK, DENIED -> ReactionError.REACTION_FAILED + TOO_MANY_REACTION_TYPES -> ReactionError.TOO_MANY_REACTION_TYPES + MESSAGE_NOT_FOUND, CANNOT_REACT -> null + } +} + +/** A reaction failure the user is told about. */ +enum class ReactionError { + REACTION_FAILED, + TOO_MANY_REACTION_TYPES, +} + +/** The outcome of an add or remove call. */ +sealed class ReactionResult { + data class Ok( + val count: Long, + val selfReacted: Boolean, + val version: Long, + val selfReactedAt: Instant?, + ) : ReactionResult() + + data class Failed(val failure: ReactionFailure) : ReactionResult() +} + +/** An emoji the user's displayed state has them reacting with, and when they reacted. */ +data class SelfReaction(val emoji: String, val reactedAt: Instant) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactionState.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactionState.kt new file mode 100644 index 0000000000..c881f08dd4 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactionState.kt @@ -0,0 +1,238 @@ +package com.flipcash.shared.chat.reactions + +import kotlin.time.Instant + +/** + * One message's reactions: the server state last accepted per emoji, merged with the user's + * pending taps. + * + * This is `gen_reactions.py`'s `Model`, and `reactions.json`'s `merge` vectors hold it to that. + * Server state (summary, stream update, successful response) is accepted only when its version is + * strictly greater than the one held for that emoji. The displayed self state is the pending tap + * when there is one, otherwise the confirmed one, so the stream never overwrites a pending tap. + * + * Only [confirmed] would be encoded on persistence: pending taps and in-flight calls do not + * survive a relaunch. + */ +class ReactionState { + + /** The last server state accepted for one emoji. */ + data class Confirmed( + val count: Long, + val selfReacted: Boolean, + val version: Long, + /** + * When the user reacted with this emoji, if they have; orders the strip and takes no part + * in the merge. + */ + val selfReactedAt: Instant?, + ) + + /** One emoji as a reaction summary lists it. */ + data class SummaryEntry( + val emoji: String, + val count: Long, + val selfReacted: Boolean, + val version: Long, + val selfReactedAt: Instant?, + ) + + /** + * The accepted server state per emoji; a count of 0 is a tombstone that keeps the emptied + * emoji's version so a late, older add is still rejected. + */ + private val confirmedByEmoji: MutableMap = mutableMapOf() + + /** The self state the user last tapped to, per emoji, until the server settles it. */ + private val desired: MutableMap = mutableMapOf() + + /** Emoji with an add or remove call on the wire. */ + private val inFlightEmoji: MutableSet = mutableSetOf() + + /** When the user tapped each emoji whose [desired] state is on. */ + private val desiredAt: MutableMap = mutableMapOf() + + val confirmed: Map get() = confirmedByEmoji + val inFlight: Set get() = inFlightEmoji + + // region Server state + + /** + * Accepts a reaction summary, which lists only non-empty emoji: a held emoji it omits has + * emptied, unless a tap on it is pending. + */ + fun applySummary(reactions: List) { + val present = reactions.map { it.emoji }.toSet() + for (entry in reactions) { + accept( + entry.emoji, + Confirmed( + count = entry.count, + selfReacted = entry.selfReacted, + version = entry.version, + selfReactedAt = entry.selfReactedAt, + ), + ) + } + for ((emoji, held) in confirmedByEmoji.toMap()) { + if (emoji !in present && desired[emoji] == null && held.count > 0) { + confirmedByEmoji[emoji] = held.copy(count = 0, selfReacted = false, selfReactedAt = null) + } + } + } + + /** Accepts one stream update; [count] is the emoji's new total, not a delta. */ + fun applyUpdate( + emoji: String, + actorIsSelf: Boolean, + added: Boolean, + count: Long, + version: Long, + reactedAt: Instant?, + ) { + val selfReacted: Boolean + val selfReactedAt: Instant? + if (actorIsSelf) { + selfReacted = added + selfReactedAt = if (added) reactedAt else null + } else { + selfReacted = confirmedByEmoji[emoji]?.selfReacted ?: false + selfReactedAt = confirmedByEmoji[emoji]?.selfReactedAt + } + accept(emoji, Confirmed(count = count, selfReacted = selfReacted, version = version, selfReactedAt = selfReactedAt)) + } + + /** + * Accepts every emoji [other] has confirmed, each by version as any server state is, and + * ignores its pending taps. + */ + fun merge(other: ReactionState) { + for ((emoji, state) in other.confirmedByEmoji) { + accept(emoji, state) + } + } + + /** The confirmed non-empty emoji, as a summary would list them. */ + val summaryEntries: List + get() = confirmedByEmoji.mapNotNull { (emoji, state) -> + if (state.count <= 0) return@mapNotNull null + SummaryEntry( + emoji = emoji, + count = state.count, + selfReacted = state.selfReacted, + version = state.version, + selfReactedAt = state.selfReactedAt, + ) + } + + /** Whether no tap is pending and no call is on the wire. */ + val isSettled: Boolean get() = desired.isEmpty() && inFlightEmoji.isEmpty() + + // endregion + + // region User actions + + /** + * Flips the displayed self state of [emoji] and returns the call to send, or null when a call + * for it is already on the wire and the response will send any follow-up. + */ + fun tap(emoji: String, at: Instant): ReactionCall? { + val on = !displayedSelf(emoji) + desired[emoji] = on + if (on) desiredAt[emoji] = at else desiredAt.remove(emoji) + if (emoji in inFlightEmoji) return null + return send(emoji) + } + + /** + * Settles the call in flight for [emoji], returning a coalesced follow-up call and the error + * to show, if any; a response for an emoji with nothing in flight is ignored. + */ + fun respond(emoji: String, result: ReactionResult): Pair { + if (!inFlightEmoji.remove(emoji)) return null to null + return when (result) { + is ReactionResult.Ok -> { + accept( + emoji, + Confirmed( + count = result.count, + selfReacted = result.selfReacted, + version = result.version, + selfReactedAt = result.selfReactedAt, + ), + ) + val wanted = desired[emoji] + if (wanted != null && wanted != (confirmedByEmoji[emoji]?.selfReacted ?: false)) { + send(emoji) to null + } else { + clearDesired(emoji) + null to null + } + } + + is ReactionResult.Failed -> { + clearDesired(emoji) + null to result.failure.userError + } + } + } + + // endregion + + // region Display + + /** The pills to show, in [ReactionOrdering]; an emoji whose displayed count is 0 has none. */ + val pills: List + get() { + val emoji = confirmedByEmoji.keys + desired.keys + val pills = emoji.mapNotNull { emoji -> + val (count, shown) = displayed(emoji) + if (count <= 0) return@mapNotNull null + ReactionPill(emoji = emoji, count = count, selfReacted = shown, pending = desired[emoji] != null) + } + return ReactionOrdering.sorted(pills) + } + + /** The emoji the user is shown reacting with, each with when they reacted. */ + val selfReactions: List + get() = (confirmedByEmoji.keys + desired.keys).mapNotNull { emoji -> + if (!displayedSelf(emoji)) return@mapNotNull null + val reactedAt = desiredAt[emoji] ?: confirmedByEmoji[emoji]?.selfReactedAt ?: Instant.DISTANT_PAST + SelfReaction(emoji = emoji, reactedAt = reactedAt) + } + + // endregion + + // region Private + + private fun accept(emoji: String, state: Confirmed) { + val held = confirmedByEmoji[emoji] + if (held != null && state.version <= held.version) return + confirmedByEmoji[emoji] = state + } + + private fun send(emoji: String): ReactionCall { + inFlightEmoji.add(emoji) + return ReactionCall(op = if (desired[emoji] == true) ReactionCall.Op.ADD else ReactionCall.Op.REMOVE, emoji = emoji) + } + + private fun clearDesired(emoji: String) { + desired.remove(emoji) + desiredAt.remove(emoji) + } + + private fun displayedSelf(emoji: String): Boolean = desired[emoji] ?: confirmedByEmoji[emoji]?.selfReacted ?: false + + /** + * The displayed count, signed because an inconsistent confirmed state (self set on a count of + * 0) must hide the pill rather than underflow. + */ + private fun displayed(emoji: String): Pair { + val held = confirmedByEmoji[emoji] + val shown = displayedSelf(emoji) + val base = (held?.count ?: 0) - (if (held?.selfReacted == true) 1 else 0) + return (base + if (shown) 1 else 0) to shown + } + + // endregion +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactionStrip.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactionStrip.kt new file mode 100644 index 0000000000..cf211ba572 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactionStrip.kt @@ -0,0 +1,31 @@ +package com.flipcash.shared.chat.reactions + +/** The emoji the long-press strip offers, before the trailing "+" the UI adds. */ +object ReactionStrip { + + /** One emoji in the strip. */ + data class Entry( + val emoji: String, + /** True when the user has reacted to the message with this emoji. */ + val highlighted: Boolean, + ) + + /** + * Returns the recents in rank order, highlighting the ones the user reacted with, followed by + * the user's other reactions, newest first. + */ + fun entries(recents: List, selfReactions: List): List { + val mine = selfReactions.map { it.emoji }.toSet() + val recentSet = recents.toSet() + // Indexed so equal times keep their input order, as the reference's stable sort does. + val extra = selfReactions.withIndex() + .sortedWith( + compareByDescending> { it.value.reactedAt } + .thenBy { it.index }, + ) + .map { it.value.emoji } + .filter { it !in recentSet } + return recents.map { Entry(emoji = it, highlighted = it in mine) } + + extra.map { Entry(emoji = it, highlighted = true) } + } +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactionStripComposer.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactionStripComposer.kt new file mode 100644 index 0000000000..6fc1e346ea --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactionStripComposer.kt @@ -0,0 +1,52 @@ +package com.flipcash.shared.chat.reactions + +import com.getcode.libs.emojis.reactions.RecentReactions + +/** + * Composes the long-press strip's 12 emoji (the "+" that opens the picker is the UI's, not this + * function's, thirteenth slot). + * + * Mirrors iOS's app-layer composition over the vector-exact `ReactionStrip.entries`: fixed + * defaults (minus undrawable), then up to six most-used beyond the defaults, then every emoji the + * user reacted with on this message (always shown, even past the cap — `ReactionStrip.entries` + * itself guarantees that), then filler from the catalog's first category up to 12 total. + */ +object ReactionStripComposer { + private const val TARGET_SIZE = 12 + private const val MOST_USED_SLOTS = 6 + + /** + * @param recentStats this device's recorded per-emoji use, as [RecentReactions.rank] takes it. + * @param selfReactions the emoji the user reacted to this message with. + * @param catalogFillSource the fill source's emoji, in the catalog's own order (the first + * category, per decision 1 — the caller resolves that; this function only fills from it). + * @param undrawable emoji this device cannot draw as one glyph, left out everywhere. + */ + fun compose( + recentStats: Map, + selfReactions: List, + catalogFillSource: List, + undrawable: Set = emptySet(), + ): List { + val fixed = RecentReactions.DEFAULTS.filter { it !in undrawable } + + val mostUsed = RecentReactions.rank(recentStats, undrawable, fixed.size + MOST_USED_SLOTS) + .filter { it !in fixed } + .take(MOST_USED_SLOTS) + + val entries = ReactionStrip.entries(fixed + mostUsed, selfReactions).toMutableList() + if (entries.size >= TARGET_SIZE) return entries.take(TARGET_SIZE) + + val present = entries.mapTo(mutableSetOf()) { it.emoji } + val selfEmoji = selfReactions.map { it.emoji }.toSet() + + for (emoji in catalogFillSource) { + if (entries.size >= TARGET_SIZE) break + if (emoji in undrawable || emoji in present) continue + entries.add(ReactionStrip.Entry(emoji = emoji, highlighted = emoji in selfEmoji)) + present.add(emoji) + } + + return entries.take(TARGET_SIZE) + } +} diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/reactions/ReactionStripComposerTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/reactions/ReactionStripComposerTest.kt new file mode 100644 index 0000000000..b7b2ffa18c --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/reactions/ReactionStripComposerTest.kt @@ -0,0 +1,100 @@ +package com.flipcash.shared.chat.reactions + +import com.getcode.libs.emojis.reactions.RecentReactions +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Instant + +class ReactionStripComposerTest { + + private val catalogFillSource = listOf("😀", "😃", "😄", "😁", "😆", "😅", "🤣", "😂", "🙂", "🙃", "😉", "😊", "😇", "😍") + + @Test + fun `with no history the strip is the six defaults padded from the catalog`() { + val entries = ReactionStripComposer.compose( + recentStats = emptyMap(), + selfReactions = emptyList(), + catalogFillSource = catalogFillSource, + ) + + assertEquals(12, entries.size) + assertEquals(RecentReactions.DEFAULTS, entries.take(6).map { it.emoji }) + assertTrue(entries.none { it.highlighted }) + // The fill is catalog emoji not already offered, in the catalog's own order. + val fill = entries.drop(6).map { it.emoji } + assertEquals(catalogFillSource.filter { it !in RecentReactions.DEFAULTS }.take(6), fill) + } + + @Test + fun `undrawable defaults are dropped, not replaced by a most-used or filler emoji in their slot`() { + val undrawable = setOf(RecentReactions.DEFAULTS[1]) + + val entries = ReactionStripComposer.compose( + recentStats = emptyMap(), + selfReactions = emptyList(), + catalogFillSource = catalogFillSource, + undrawable = undrawable, + ) + + assertTrue(entries.none { it.emoji in undrawable }) + assertEquals(RecentReactions.DEFAULTS.filter { it !in undrawable }, entries.take(5).map { it.emoji }) + } + + @Test + fun `most-used emoji beyond the defaults fill the next six slots, ranked`() { + val stats = mapOf( + "🥳" to RecentReactions.Usage(count = 10, lastUsed = Instant.fromEpochSeconds(100)), + "🤔" to RecentReactions.Usage(count = 5, lastUsed = Instant.fromEpochSeconds(200)), + ) + + val entries = ReactionStripComposer.compose( + recentStats = stats, + selfReactions = emptyList(), + catalogFillSource = catalogFillSource, + ) + + val afterDefaults = entries.drop(6).map { it.emoji } + assertEquals(listOf("🥳", "🤔"), afterDefaults.take(2)) + } + + @Test + fun `a self reaction outside the defaults and most-used is always included, highlighted`() { + val entries = ReactionStripComposer.compose( + recentStats = emptyMap(), + selfReactions = listOf(SelfReaction(emoji = "🦄", reactedAt = Instant.fromEpochSeconds(1))), + catalogFillSource = catalogFillSource, + ) + + val unicorn = entries.firstOrNull { it.emoji == "🦄" } + assertTrue(unicorn != null, "self reaction was dropped from the strip") + assertTrue(unicorn.highlighted) + } + + @Test + fun `never returns more than 12 entries`() { + val manyStats = (1..20).associate { "emoji$it" to RecentReactions.Usage(count = it, lastUsed = Instant.fromEpochSeconds(it.toLong())) } + val manySelf = (1..20).map { SelfReaction(emoji = "self$it", reactedAt = Instant.fromEpochSeconds(it.toLong())) } + + val entries = ReactionStripComposer.compose( + recentStats = manyStats, + selfReactions = manySelf, + catalogFillSource = catalogFillSource, + ) + + assertEquals(12, entries.size) + } + + @Test + fun `a default already ranked as most-used is not duplicated`() { + val stats = mapOf(RecentReactions.DEFAULTS[0] to RecentReactions.Usage(count = 100, lastUsed = Instant.fromEpochSeconds(1))) + + val entries = ReactionStripComposer.compose( + recentStats = stats, + selfReactions = emptyList(), + catalogFillSource = catalogFillSource, + ) + + assertEquals(1, entries.count { it.emoji == RecentReactions.DEFAULTS[0] }) + } +} diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/reactions/ReactionVectorsTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/reactions/ReactionVectorsTest.kt new file mode 100644 index 0000000000..2e8ea237e2 --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/reactions/ReactionVectorsTest.kt @@ -0,0 +1,222 @@ +package com.flipcash.shared.chat.reactions + +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * `test-vectors/reactions.json`. The canonical copy lives in the orchestrator repo (#17); this one + * is synced verbatim. A failure here is either a real regression or a cross-platform decision that + * has to be made in the canonical fixture and re-synced to both platforms — never a local edit. + * + * Covers the `merge`, `order` and `strip` sections; `defaults`, `recents` and `drawability` are + * covered by `libs:emojis`'s `RecentReactionsVectorTest`, next to where `RecentReactions` lives. + * + * `merge` replays each vector's steps against a fresh [ReactionState] — the same state machine as + * iOS's `FlipcashCore` `ReactionState` and `gen_reactions.py`'s reference `Model` — and checks the + * resulting pills, the calls it would send, and the errors it would show. + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class ReactionVectorsTest { + + @Test + fun `merge vectors match the reactions fixture`() { + val vectors = section("merge") + assertTrue(vectors.length() > 0, "reactions.json loaded no merge vectors") + + for (i in 0 until vectors.length()) { + val vector = vectors.getJSONObject(i) + val name = vector.getString("name") + val note = vector.getString("note") + val self = vector.getString("self") + + val state = ReactionState() + val calls = mutableListOf() + val errors = mutableListOf() + + val steps = vector.getJSONArray("steps") + for (s in 0 until steps.length()) { + val step = steps.getJSONObject(s) + when (val op = step.getString("op")) { + "summary" -> { + val reactions = step.getJSONArray("reactions") + val entries = (0 until reactions.length()).map { k -> + val r = reactions.getJSONObject(k) + ReactionState.SummaryEntry( + emoji = r.getString("emoji"), + count = r.getLong("count"), + selfReacted = r.getBoolean("self"), + version = r.getLong("version"), + selfReactedAt = null, + ) + } + state.applySummary(entries) + } + + "update" -> { + state.applyUpdate( + emoji = step.getString("emoji"), + actorIsSelf = step.getString("actor") == self, + added = step.getString("action") == "ADDED", + count = step.getLong("count"), + version = step.getLong("version"), + reactedAt = EPOCH, + ) + } + + "tap" -> { + val call = state.tap(step.getString("emoji"), at = EPOCH) + if (call != null) calls.add(call) + } + + "respond" -> { + val result = step.getString("result") + val outcome = if (result == "OK") { + val reaction = step.getJSONObject("reaction") + ReactionResult.Ok( + count = reaction.getLong("count"), + selfReacted = reaction.getBoolean("self"), + version = reaction.getLong("version"), + selfReactedAt = null, + ) + } else { + ReactionResult.Failed(failureOf(result)) + } + val (call, error) = state.respond(step.getString("emoji"), outcome) + if (call != null) calls.add(call) + if (error != null) errors.add(error) + } + + else -> error("unknown op `$op`") + } + } + + val expect = vector.getJSONObject("expect") + assertPills(expect.getJSONArray("pills"), state.pills, name, note) + assertCalls(expect.getJSONArray("calls"), calls, name, note) + assertErrors(expect.getJSONArray("errors"), errors, name, note) + } + } + + @Test + fun `order vectors match the reactions fixture`() { + val vectors = section("order") + assertTrue(vectors.length() > 0, "reactions.json loaded no order vectors") + + for (i in 0 until vectors.length()) { + val vector = vectors.getJSONObject(i) + val name = vector.getString("name") + val note = vector.getString("note") + + val pillsJson = vector.getJSONArray("pills") + val pills = (0 until pillsJson.length()).map { k -> + val p = pillsJson.getJSONObject(k) + val boostTotal = p.getLong("boostTotal") + ReactionPill( + emoji = p.getString("emoji"), + count = p.getLong("count"), + selfReacted = false, + pending = false, + boost = if (boostTotal != 0L) ReactionBoost(boostTotal) else null, + ) + } + + val expected = vector.getJSONArray("expect") + val expectedOrder = (0 until expected.length()).map { expected.getString(it) } + val actualOrder = ReactionOrdering.sorted(pills).map { it.emoji } + assertEquals(expectedOrder, actualOrder, "vector `$name`: $note") + } + } + + @Test + fun `strip vectors match the reactions fixture`() { + val vectors = section("strip") + assertTrue(vectors.length() > 0, "reactions.json loaded no strip vectors") + + for (i in 0 until vectors.length()) { + val vector = vectors.getJSONObject(i) + val name = vector.getString("name") + val note = vector.getString("note") + + val recentsJson = vector.getJSONArray("recents") + val recents = (0 until recentsJson.length()).map { recentsJson.getString(it) } + + val selfReactionsJson = vector.getJSONArray("selfReactions") + val selfReactions = (0 until selfReactionsJson.length()).map { k -> + val r = selfReactionsJson.getJSONObject(k) + SelfReaction(emoji = r.getString("emoji"), reactedAt = Instant.fromEpochSeconds(r.getLong("reactedAt"))) + } + + val expectJson = vector.getJSONArray("expect") + val expected = (0 until expectJson.length()).map { k -> + val e = expectJson.getJSONObject(k) + ReactionStrip.Entry(emoji = e.getString("emoji"), highlighted = e.getBoolean("highlighted")) + } + + val actual = ReactionStrip.entries(recents, selfReactions) + assertEquals(expected, actual, "vector `$name`: $note") + } + } + + private fun assertPills(expected: JSONArray, actual: List, name: String, note: String) { + assertEquals(expected.length(), actual.size, "vector `$name` pill count: $note") + for (k in 0 until expected.length()) { + val e = expected.getJSONObject(k) + val a = actual[k] + assertEquals(e.getString("emoji"), a.emoji, "vector `$name` pill[$k].emoji: $note") + assertEquals(e.getLong("count"), a.count, "vector `$name` pill[$k].count: $note") + assertEquals(e.getBoolean("selfReacted"), a.selfReacted, "vector `$name` pill[$k].selfReacted: $note") + assertEquals(e.getBoolean("pending"), a.pending, "vector `$name` pill[$k].pending: $note") + } + } + + private fun assertCalls(expected: JSONArray, actual: List, name: String, note: String) { + assertEquals(expected.length(), actual.size, "vector `$name` call count: $note") + for (k in 0 until expected.length()) { + val e = expected.getJSONObject(k) + val a = actual[k] + val expectedOp = if (e.getString("op") == "add") ReactionCall.Op.ADD else ReactionCall.Op.REMOVE + assertEquals(expectedOp, a.op, "vector `$name` call[$k].op: $note") + assertEquals(e.getString("emoji"), a.emoji, "vector `$name` call[$k].emoji: $note") + } + } + + private fun assertErrors(expected: JSONArray, actual: List, name: String, note: String) { + assertEquals(expected.length(), actual.size, "vector `$name` error count: $note") + for (k in 0 until expected.length()) { + val expectedError = when (val wire = expected.getString(k)) { + "reactionFailed" -> ReactionError.REACTION_FAILED + "tooManyReactionTypes" -> ReactionError.TOO_MANY_REACTION_TYPES + else -> error("unknown error `$wire`") + } + assertEquals(expectedError, actual[k], "vector `$name` error[$k]: $note") + } + } + + private fun failureOf(result: String): ReactionFailure = when (result) { + "NETWORK" -> ReactionFailure.NETWORK + "DENIED" -> ReactionFailure.DENIED + "MESSAGE_NOT_FOUND" -> ReactionFailure.MESSAGE_NOT_FOUND + "CANNOT_REACT" -> ReactionFailure.CANNOT_REACT + "TOO_MANY_REACTION_TYPES" -> ReactionFailure.TOO_MANY_REACTION_TYPES + else -> error("unknown result `$result`") + } + + private fun section(name: String): JSONArray { + val json = javaClass.classLoader!! + .getResourceAsStream("reactions.json")!! + .bufferedReader().use { it.readText() } + return JSONObject(json).getJSONArray(name) + } + + private companion object { + val EPOCH: Instant = Instant.fromEpochSeconds(0) + } +} diff --git a/libs/emojis/build.gradle.kts b/libs/emojis/build.gradle.kts index e3aa087d95..dbad6a2a63 100644 --- a/libs/emojis/build.gradle.kts +++ b/libs/emojis/build.gradle.kts @@ -11,6 +11,10 @@ dependencies { implementation(libs.bundles.kotlinx.serialization) implementation(libs.kotlinx.datetime) implementation(libs.androidx.datastore) + + testImplementation(kotlin("test")) + testImplementation(libs.bundles.unit.testing) + testImplementation(libs.robolectric) } // Define the task to fetch and generate emoji data diff --git a/libs/emojis/src/main/kotlin/com/getcode/libs/emojis/EmojiUsageController.kt b/libs/emojis/src/main/kotlin/com/getcode/libs/emojis/EmojiUsageController.kt index d47b4cec90..4fd032dfd1 100644 --- a/libs/emojis/src/main/kotlin/com/getcode/libs/emojis/EmojiUsageController.kt +++ b/libs/emojis/src/main/kotlin/com/getcode/libs/emojis/EmojiUsageController.kt @@ -1,5 +1,6 @@ package com.getcode.libs.emojis +import com.getcode.libs.emojis.reactions.RecentReactions import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -7,8 +8,10 @@ class EmojiUsageController( private val queryProvider: EmojiQueryProvider ): EmojiUsageTracker { private companion object { - val DEFAULT_TOP = listOf("👍", "👎", "❤️", "🙌", "😂") - private const val MAX_EMOJIS = 5 + // The reaction strip's six defaults, so the picker's "frequently used" filler and the + // strip agree on what an emoji-less history falls back to. + val DEFAULT_TOP = RecentReactions.DEFAULTS + private val MAX_EMOJIS = RecentReactions.DEFAULTS.size } override suspend fun mostUsedEmojis(includeFiller: Boolean): List { diff --git a/libs/emojis/src/main/kotlin/com/getcode/libs/emojis/reactions/EmojiCatalog.kt b/libs/emojis/src/main/kotlin/com/getcode/libs/emojis/reactions/EmojiCatalog.kt new file mode 100644 index 0000000000..79d32ec758 --- /dev/null +++ b/libs/emojis/src/main/kotlin/com/getcode/libs/emojis/reactions/EmojiCatalog.kt @@ -0,0 +1,76 @@ +package com.getcode.libs.emojis.reactions + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +/** One emoji as `emoji_catalog.json` (bundled verbatim from the orchestrator repo) lists it. */ +@Serializable +data class EmojiCatalogEntry( + val emoji: String, + val name: String, + val category: String, + val version: String, + val skinTone: Boolean, + val keywords: List = emptyList(), +) + +/** `emoji_catalog.json`'s categories, in the file's own order, and the entries in each. */ +data class EmojiCatalog( + val categories: List, + val entries: List, +) { + val byCategory: Map> by lazy { + entries.groupBy { it.category } + } + + /** The first category's entries, the app-layer strip's fill source (decision 1). */ + val firstCategoryEntries: List + get() = categories.firstOrNull()?.let { byCategory[it] }.orEmpty() +} + +@Serializable +private data class EmojiCatalogFile( + val categories: List, + val emoji: List, +) + +/** + * Loads [EmojiCatalog] from the bundled `emoji_catalog.json` resource once, on + * [dispatcher] (default [Dispatchers.Default], since parsing is CPU-bound, not I/O), and caches + * the result for the loader's lifetime. + */ +class EmojiCatalogLoader( + private val dispatcher: CoroutineDispatcher = Dispatchers.Default, + private val resourceLoader: () -> String = { defaultResource() }, +) { + private val mutex = Mutex() + private var cached: EmojiCatalog? = null + + suspend fun load(): EmojiCatalog { + cached?.let { return it } + return mutex.withLock { + cached?.let { return@withLock it } + withContext(dispatcher) { + val text = resourceLoader() + val file = json.decodeFromString(EmojiCatalogFile.serializer(), text) + EmojiCatalog(categories = file.categories, entries = file.emoji).also { cached = it } + } + } + } + + private companion object { + val json = Json { ignoreUnknownKeys = true } + + fun defaultResource(): String { + val stream = EmojiCatalogLoader::class.java.classLoader + ?.getResourceAsStream("emoji_catalog.json") + ?: error("emoji_catalog.json resource not found") + return stream.bufferedReader().use { it.readText() } + } + } +} diff --git a/libs/emojis/src/main/kotlin/com/getcode/libs/emojis/reactions/EmojiDrawability.kt b/libs/emojis/src/main/kotlin/com/getcode/libs/emojis/reactions/EmojiDrawability.kt new file mode 100644 index 0000000000..e5d9868729 --- /dev/null +++ b/libs/emojis/src/main/kotlin/com/getcode/libs/emojis/reactions/EmojiDrawability.kt @@ -0,0 +1,29 @@ +package com.getcode.libs.emojis.reactions + +import android.graphics.Paint + +/** Answers whether this device draws an emoji as one glyph, matching iOS's `EmojiDrawability`. */ +object EmojiDrawability { + + /** + * Returns whether this device draws [emoji] as a single glyph. + * + * [Paint.hasGlyph] asks the platform's font fallback whether a string resolves to one glyph + * (a ligature for a ZWJ/skin-tone sequence, or the base glyph for a plain emoji) rather than + * falling apart into several tofu or component glyphs side by side — the same question iOS's + * `CTLine` run probe answers by counting AppleColorEmoji glyphs. + */ + fun isDrawable(emoji: String, probe: GlyphProbe = PaintGlyphProbe): Boolean = probe.isSingleGlyph(emoji) + + /** Seam so the merge logic that consumes drawability can be tested without a real typeface. */ + fun interface GlyphProbe { + fun isSingleGlyph(text: String): Boolean + } + + /** The real, device-backed probe: [android.graphics.Paint.hasGlyph]. */ + object PaintGlyphProbe : GlyphProbe { + private val paint = Paint() + + override fun isSingleGlyph(text: String): Boolean = paint.hasGlyph(text) + } +} diff --git a/libs/emojis/src/main/kotlin/com/getcode/libs/emojis/reactions/ReactionsModule.kt b/libs/emojis/src/main/kotlin/com/getcode/libs/emojis/reactions/ReactionsModule.kt new file mode 100644 index 0000000000..8e994f19f0 --- /dev/null +++ b/libs/emojis/src/main/kotlin/com/getcode/libs/emojis/reactions/ReactionsModule.kt @@ -0,0 +1,23 @@ +package com.getcode.libs.emojis.reactions + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object ReactionsModule { + + @Provides + @Singleton + fun providesRecentReactionsStore(@ApplicationContext context: Context): RecentReactionsStore = + DataStoreRecentReactionsStore(context) + + @Provides + @Singleton + fun providesEmojiCatalogLoader(): EmojiCatalogLoader = EmojiCatalogLoader() +} diff --git a/libs/emojis/src/main/kotlin/com/getcode/libs/emojis/reactions/RecentReactions.kt b/libs/emojis/src/main/kotlin/com/getcode/libs/emojis/reactions/RecentReactions.kt new file mode 100644 index 0000000000..04221bfd10 --- /dev/null +++ b/libs/emojis/src/main/kotlin/com/getcode/libs/emojis/reactions/RecentReactions.kt @@ -0,0 +1,60 @@ +package com.getcode.libs.emojis.reactions + +import kotlin.time.Instant + +/** Ranks the emoji the strip and picker offer first, from how often and how recently each was used. */ +object RecentReactions { + + /** The emoji offered before any have been used, in their order. */ + val DEFAULTS: List = listOf("❤️", "👍", "😂", "😮", "😢", "🔥") + + /** How often and how recently one emoji was reacted with. */ + data class Usage(val count: Int, val lastUsed: Instant) + + /** Records one use of [emoji] at [date] in [stats], returning the updated map. */ + fun record(emoji: String, at: Instant, stats: Map): Map { + val existing = stats[emoji] + val updated = if (existing != null) { + existing.copy(count = existing.count + 1, lastUsed = maxOf(existing.lastUsed, at)) + } else { + Usage(count = 1, lastUsed = at) + } + return stats + (emoji to updated) + } + + /** + * Returns up to [limit] emoji: the used ones by count descending, then last use descending, + * padded with [DEFAULTS], leaving out any in [undrawable]. + * + * Padding stops at the defaults, so an unused picker asking for more than six gets six. + */ + fun rank(stats: Map, undrawable: Set, limit: Int): List { + val ranked = stats.entries + .sortedWith( + compareByDescending> { it.value.count } + .thenByDescending { it.value.lastUsed } + // Only for a map's lack of order; two uses at the same instant do not happen. + .thenComparator { a, b -> compareUtf8Bytes(a.key, b.key) }, + ) + .map { it.key } + .filter { it !in undrawable } + .toMutableList() + for (emoji in DEFAULTS) { + if (emoji !in ranked && emoji !in undrawable) { + ranked.add(emoji) + } + } + return ranked.take(maxOf(limit, 0)) + } + + private fun compareUtf8Bytes(lhs: String, rhs: String): Int { + val a = lhs.encodeToByteArray() + val b = rhs.encodeToByteArray() + val len = minOf(a.size, b.size) + for (i in 0 until len) { + val cmp = (a[i].toInt() and 0xFF) - (b[i].toInt() and 0xFF) + if (cmp != 0) return cmp + } + return a.size - b.size + } +} diff --git a/libs/emojis/src/main/kotlin/com/getcode/libs/emojis/reactions/RecentReactionsStore.kt b/libs/emojis/src/main/kotlin/com/getcode/libs/emojis/reactions/RecentReactionsStore.kt new file mode 100644 index 0000000000..4b524cdd57 --- /dev/null +++ b/libs/emojis/src/main/kotlin/com/getcode/libs/emojis/reactions/RecentReactionsStore.kt @@ -0,0 +1,72 @@ +package com.getcode.libs.emojis.reactions + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.first +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Clock +import kotlin.time.Instant + +private val Context.recentReactionsDataStore: DataStore by preferencesDataStore(name = "recent_reactions") + +/** Per-emoji use counts and last-used times, backing [RecentReactions.rank]. */ +interface RecentReactionsStore { + + /** Records one use of [emoji] now. Only an add records — a remove does not. */ + suspend fun record(emoji: String) + + /** Returns up to [limit] emoji in rank order, leaving out [undrawable]. */ + suspend fun rank(undrawable: Set = emptySet(), limit: Int = RecentReactions.DEFAULTS.size): List +} + +@Singleton +class DataStoreRecentReactionsStore @Inject constructor( + @ApplicationContext context: Context, +) : RecentReactionsStore { + + private val dataStore = context.recentReactionsDataStore + + override suspend fun record(emoji: String) { + val now = Clock.System.now() + dataStore.edit { prefs -> + val key = key(emoji) + val current = decode(prefs[key]) + prefs[key] = encode(RecentReactions.Usage(count = current.count + 1, lastUsed = now)) + } + } + + override suspend fun rank(undrawable: Set, limit: Int): List { + val prefs = dataStore.data.first() + val stats = prefs.asMap().mapNotNull { (prefsKey, raw) -> + val emoji = prefsKey.name.removePrefix(KEY_PREFIX) + if (emoji == prefsKey.name) return@mapNotNull null // not one of ours + val usage = decode(raw as? String) ?: return@mapNotNull null + emoji to usage + }.toMap() + return RecentReactions.rank(stats, undrawable, limit) + } + + private fun key(emoji: String) = stringPreferencesKey("$KEY_PREFIX$emoji") + + private fun encode(usage: RecentReactions.Usage): String = "${usage.count}:${usage.lastUsed.epochSeconds}" + + private fun decode(raw: String?): RecentReactions.Usage { + if (raw == null) return RecentReactions.Usage(count = 0, lastUsed = Instant.fromEpochSeconds(0)) + val separator = raw.indexOf(':') + if (separator < 0) return RecentReactions.Usage(count = 0, lastUsed = Instant.fromEpochSeconds(0)) + val count = raw.substring(0, separator).toIntOrNull() ?: 0 + val lastUsed = raw.substring(separator + 1).toLongOrNull()?.let { Instant.fromEpochSeconds(it) } + ?: Instant.fromEpochSeconds(0) + return RecentReactions.Usage(count = count, lastUsed = lastUsed) + } + + private companion object { + const val KEY_PREFIX = "emoji:" + } +} diff --git a/libs/emojis/src/main/resources/emoji_catalog.json b/libs/emojis/src/main/resources/emoji_catalog.json new file mode 100644 index 0000000000..09ecb2b8e3 --- /dev/null +++ b/libs/emojis/src/main/resources/emoji_catalog.json @@ -0,0 +1,3953 @@ +{ + "emojiVersion":"17.0.0", + "cldr":"48.2.2", + "gomoji":"v1.4.1", + "categories":["Smileys & People","Animals & Nature","Food & Drink","Travel & Places","Activities","Objects","Symbols","Flags"], + "dropped":[], + "emoji":[ + {"emoji":"😀","name":"grinning face","category":"Smileys & People","version":"1.0","skinTone":false,"keywords":["grinning","face","cheerful","cheery","grin","happy","laugh","nice","smile","smiling","teeth"]}, + {"emoji":"😃","name":"grinning face with big eyes","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["grinning","face","with","big","eyes","awesome","grin","happy","mouth","open","smile","smiling","teeth","yay"]}, + {"emoji":"😄","name":"grinning face with smiling eyes","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["grinning","face","with","smiling","eyes","eye","grin","happy","laugh","lol","mouth","open","smile"]}, + {"emoji":"😁","name":"beaming face with smiling eyes","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["beaming","face","with","smiling","eyes","eye","grin","grinning","happy","nice","smile","teeth"]}, + {"emoji":"😆","name":"grinning squinting face","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["grinning","squinting","face","closed","eyes","haha","hahaha","happy","laugh","lol","mouth","open","rofl","smile","smiling"]}, + {"emoji":"😅","name":"grinning face with sweat","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["grinning","face","with","sweat","cold","dejected","excited","mouth","nervous","open","smile","smiling","stress","stressed"]}, + {"emoji":"🤣","name":"rolling on the floor laughing","category":"Smileys & People","version":"3.0","skinTone":false,"keywords":["rolling","on","the","floor","laughing","crying","face","funny","haha","happy","hehe","hilarious","joy","laugh","lmao","lol","rofl","roflmao","tear"]}, + {"emoji":"😂","name":"face with tears of joy","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["face","with","tears","of","joy","crying","feels","funny","haha","happy","hehe","hilarious","laugh","lmao","lol","rofl","roflmao","tear"]}, + {"emoji":"🙂","name":"slightly smiling face","category":"Smileys & People","version":"1.0","skinTone":false,"keywords":["slightly","smiling","face","happy","smile"]}, + {"emoji":"🙃","name":"upside-down face","category":"Smileys & People","version":"1.0","skinTone":false,"keywords":["upside-down","face","hehe","smile"]}, + {"emoji":"🫠","name":"melting face","category":"Smileys & People","version":"14.0","skinTone":false,"keywords":["melting","face","disappear","dissolve","embarrassed","haha","heat","hot","liquid","lol","melt","sarcasm","sarcastic"]}, + {"emoji":"😉","name":"winking face","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["winking","face","flirt","heartbreaker","sexy","slide","tease","wink","winks"]}, + {"emoji":"😊","name":"smiling face with smiling eyes","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["smiling","face","with","eyes","blush","eye","glad","satisfied","smile"]}, + {"emoji":"😇","name":"smiling face with halo","category":"Smileys & People","version":"1.0","skinTone":false,"keywords":["smiling","face","with","halo","angel","angelic","angels","blessed","fairy","fairytale","fantasy","happy","innocent","peaceful","smile","spirit","tale"]}, + {"emoji":"🥰","name":"smiling face with hearts","category":"Smileys & People","version":"11.0","skinTone":false,"keywords":["smiling","face","with","hearts","3","adore","crush","heart","ily","love","romance","smile","you"]}, + {"emoji":"😍","name":"smiling face with heart-eyes","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["smiling","face","with","heart-eyes","143","bae","eye","feels","hearts","ily","kisses","love","romance","romantic","smile","xoxo"]}, + {"emoji":"🤩","name":"star-struck","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["star-struck","excited","eyes","face","grinning","smile","star","starry-eyed","wow"]}, + {"emoji":"😘","name":"face blowing a kiss","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["face","blowing","a","kiss","adorbs","bae","flirt","heart","ily","love","lover","miss","muah","romantic","smooch","xoxo","you"]}, + {"emoji":"😗","name":"kissing face","category":"Smileys & People","version":"1.0","skinTone":false,"keywords":["kissing","face","143","date","dating","flirt","ily","kiss","love","smooch","smooches","xoxo","you"]}, + {"emoji":"☺️","name":"smiling face","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["smiling","face"]}, + {"emoji":"😚","name":"kissing face with closed eyes","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["kissing","face","with","closed","eyes","143","bae","blush","date","dating","eye","flirt","ily","kisses","smooches","xoxo"]}, + {"emoji":"😙","name":"kissing face with smiling eyes","category":"Smileys & People","version":"1.0","skinTone":false,"keywords":["kissing","face","with","smiling","eyes","143","closed","date","dating","eye","flirt","ily","kiss","kisses","love","night","smile"]}, + {"emoji":"🥲","name":"smiling face with tear","category":"Smileys & People","version":"13.0","skinTone":false,"keywords":["smiling","face","with","tear","glad","grateful","happy","joy","pain","proud","relieved","smile","smiley","touched"]}, + {"emoji":"😋","name":"face savoring food","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["face","savoring","food","delicious","eat","full","hungry","savor","smile","smiling","tasty","um","yum","yummy"]}, + {"emoji":"😛","name":"face with tongue","category":"Smileys & People","version":"1.0","skinTone":false,"keywords":["face","with","tongue","awesome","cool","nice","party","stuck-out","sweet"]}, + {"emoji":"😜","name":"winking face with tongue","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["winking","face","with","tongue","crazy","epic","eye","funny","joke","loopy","nutty","party","stuck-out","wacky","weirdo","wink","yolo"]}, + {"emoji":"🤪","name":"zany face","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["zany","face","crazy","eye","eyes","goofy","large","small"]}, + {"emoji":"😝","name":"squinting face with tongue","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["squinting","face","with","tongue","closed","eye","eyes","gross","horrible","omg","stuck-out","taste","whatever","yolo"]}, + {"emoji":"🤑","name":"money-mouth face","category":"Smileys & People","version":"1.0","skinTone":false,"keywords":["money-mouth","face","money","mouth","paid"]}, + {"emoji":"🤗","name":"smiling face with open hands","category":"Smileys & People","version":"1.0","skinTone":false,"keywords":["smiling","face","with","open","hands","hug","hugging"]}, + {"emoji":"🤭","name":"face with hand over mouth","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["face","with","hand","over","mouth","giggle","giggling","oops","realization","secret","shock","sudden","surprise","whoops"]}, + {"emoji":"🫢","name":"face with open eyes and hand over mouth","category":"Smileys & People","version":"14.0","skinTone":false,"keywords":["face","with","open","eyes","and","hand","over","mouth","amazement","awe","disbelief","embarrass","gasp","omg","quiet","scared","shock","surprise"]}, + {"emoji":"🫣","name":"face with peeking eye","category":"Smileys & People","version":"14.0","skinTone":false,"keywords":["face","with","peeking","eye","captivated","embarrass","hide","hiding","peek","peep","scared","shy","stare"]}, + {"emoji":"🤫","name":"shushing face","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["shushing","face","quiet","shh","shush"]}, + {"emoji":"🤔","name":"thinking face","category":"Smileys & People","version":"1.0","skinTone":false,"keywords":["thinking","face","chin","consider","hmm","ponder","pondering","wondering"]}, + {"emoji":"🫡","name":"saluting face","category":"Smileys & People","version":"14.0","skinTone":false,"keywords":["saluting","face","good","luck","ma’am","OK","respect","salute","sir","troops","yes"]}, + {"emoji":"🤐","name":"zipper-mouth face","category":"Smileys & People","version":"1.0","skinTone":false,"keywords":["zipper-mouth","face","keep","mouth","quiet","secret","shut","zip","zipper"]}, + {"emoji":"🤨","name":"face with raised eyebrow","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["face","with","raised","eyebrow","disapproval","disbelief","distrust","emoji","hmm","mild","skeptic","skeptical","skepticism","surprise","what"]}, + {"emoji":"😐","name":"neutral face","category":"Smileys & People","version":"0.7","skinTone":false,"keywords":["neutral","face","awkward","blank","deadpan","expressionless","fine","jealous","meh","oh","shade","straight","unamused","unhappy","unimpressed","whatever"]}, + {"emoji":"😑","name":"expressionless face","category":"Smileys & People","version":"1.0","skinTone":false,"keywords":["expressionless","face","awkward","dead","fine","inexpressive","jealous","meh","not","oh","omg","straight","uh","unhappy","unimpressed","whatever"]}, + {"emoji":"😶","name":"face without mouth","category":"Smileys & People","version":"1.0","skinTone":false,"keywords":["face","without","mouth","awkward","blank","expressionless","mouthless","mute","quiet","secret","silence","silent","speechless"]}, + {"emoji":"🫥","name":"dotted line face","category":"Smileys & People","version":"14.0","skinTone":false,"keywords":["dotted","line","face","depressed","disappear","hidden","hide","introvert","invisible","meh","whatever","wtv"]}, + {"emoji":"😶‍🌫️","name":"face in clouds","category":"Smileys & People","version":"13.1","skinTone":false,"keywords":["face","in","clouds"]}, + {"emoji":"😏","name":"smirking face","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["smirking","face","boss","dapper","flirt","homie","kidding","leer","shade","slick","sly","smirk","smug","snicker","suave","suspicious","swag"]}, + {"emoji":"😒","name":"unamused face","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["unamused","face","...","bored","fine","jealous","jel","jelly","pissed","smh","ugh","uhh","unhappy","weird","whatever"]}, + {"emoji":"🙄","name":"face with rolling eyes","category":"Smileys & People","version":"1.0","skinTone":false,"keywords":["face","with","rolling","eyes","eyeroll","shade","ugh","whatever"]}, + {"emoji":"😬","name":"grimacing face","category":"Smileys & People","version":"1.0","skinTone":false,"keywords":["grimacing","face","awk","awkward","dentist","grimace","grinning","smile","smiling"]}, + {"emoji":"😮‍💨","name":"face exhaling","category":"Smileys & People","version":"13.1","skinTone":false,"keywords":["face","exhaling","blow","blowing","exhale","exhausted","gasp","groan","relief","sigh","smiley","smoke","whisper","whistle"]}, + {"emoji":"🤥","name":"lying face","category":"Smileys & People","version":"3.0","skinTone":false,"keywords":["lying","face","liar","lie","pinocchio"]}, + {"emoji":"🫨","name":"shaking face","category":"Smileys & People","version":"15.0","skinTone":false,"keywords":["shaking","face","crazy","daze","earthquake","omg","panic","shock","surprise","vibrate","whoa","wow"]}, + {"emoji":"🙂‍↔️","name":"head shaking horizontally","category":"Smileys & People","version":"15.1","skinTone":false,"keywords":["head","shaking","horizontally"]}, + {"emoji":"🙂‍↕️","name":"head shaking vertically","category":"Smileys & People","version":"15.1","skinTone":false,"keywords":["head","shaking","vertically"]}, + {"emoji":"😌","name":"relieved face","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["relieved","face","calm","peace","relief","zen"]}, + {"emoji":"😔","name":"pensive face","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["pensive","face","awful","bored","dejected","died","disappointed","losing","lost","sad","sucks"]}, + {"emoji":"😪","name":"sleepy face","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["sleepy","face","crying","good","night","sad","sleep","sleeping","tired"]}, + {"emoji":"🤤","name":"drooling face","category":"Smileys & People","version":"3.0","skinTone":false,"keywords":["drooling","face"]}, + {"emoji":"😴","name":"sleeping face","category":"Smileys & People","version":"1.0","skinTone":false,"keywords":["sleeping","face","bed","bedtime","good","goodnight","nap","night","sleep","tired","whatever","yawn","zzz"]}, + {"emoji":"🫩","name":"face with bags under eyes","category":"Smileys & People","version":"16.0","skinTone":false,"keywords":["face","with","bags","under","eyes","bored","exhausted","fatigued","late","sleepy","tired","weary"]}, + {"emoji":"😷","name":"face with medical mask","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["face","with","medical","mask","cold","dentist","dermatologist","doctor","dr","germs","medicine","sick"]}, + {"emoji":"🤒","name":"face with thermometer","category":"Smileys & People","version":"1.0","skinTone":false,"keywords":["face","with","thermometer","ill","sick"]}, + {"emoji":"🤕","name":"face with head-bandage","category":"Smileys & People","version":"1.0","skinTone":false,"keywords":["face","with","head-bandage","bandage","hurt","injury","ouch"]}, + {"emoji":"🤢","name":"nauseated face","category":"Smileys & People","version":"3.0","skinTone":false,"keywords":["nauseated","face","gross","nasty","sick","vomit"]}, + {"emoji":"🤮","name":"face vomiting","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["face","vomiting","barf","ew","gross","puke","sick","spew","throw","up","vomit"]}, + {"emoji":"🤧","name":"sneezing face","category":"Smileys & People","version":"3.0","skinTone":false,"keywords":["sneezing","face","fever","flu","gesundheit","sick","sneeze"]}, + {"emoji":"🥵","name":"hot face","category":"Smileys & People","version":"11.0","skinTone":false,"keywords":["hot","face","dying","feverish","heat","panting","red-faced","stroke","sweating","tongue"]}, + {"emoji":"🥶","name":"cold face","category":"Smileys & People","version":"11.0","skinTone":false,"keywords":["cold","face","blue","blue-faced","freezing","frostbite","icicles","subzero","teeth"]}, + {"emoji":"🥴","name":"woozy face","category":"Smileys & People","version":"11.0","skinTone":false,"keywords":["woozy","face","dizzy","drunk","eyes","intoxicated","mouth","tipsy","uneven","wavy"]}, + {"emoji":"😵","name":"face with crossed-out eyes","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["face","with","crossed-out","eyes","dead","dizzy","feels","knocked","out","sick","tired"]}, + {"emoji":"😵‍💫","name":"face with spiral eyes","category":"Smileys & People","version":"13.1","skinTone":false,"keywords":["face","with","spiral","eyes","confused","dizzy","hypnotized","omg","smiley","trouble","whoa","woah","woozy"]}, + {"emoji":"🤯","name":"exploding head","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["exploding","head","blown","explode","mind","mindblown","no","shocked","way"]}, + {"emoji":"🤠","name":"cowboy hat face","category":"Smileys & People","version":"3.0","skinTone":false,"keywords":["cowboy","hat","face","cowgirl"]}, + {"emoji":"🥳","name":"partying face","category":"Smileys & People","version":"11.0","skinTone":false,"keywords":["partying","face","bday","birthday","celebrate","celebration","excited","happy","hat","hooray","horn","party"]}, + {"emoji":"🥸","name":"disguised face","category":"Smileys & People","version":"13.0","skinTone":false,"keywords":["disguised","face","disguise","eyebrow","glasses","incognito","moustache","mustache","nose","person","spy","tache","tash"]}, + {"emoji":"😎","name":"smiling face with sunglasses","category":"Smileys & People","version":"1.0","skinTone":false,"keywords":["smiling","face","with","sunglasses","awesome","beach","bright","bro","chilling","cool","rad","relaxed","shades","slay","smile","style","swag","win"]}, + {"emoji":"🤓","name":"nerd face","category":"Smileys & People","version":"1.0","skinTone":false,"keywords":["nerd","face","brainy","clever","expert","geek","gifted","glasses","intelligent","smart"]}, + {"emoji":"🧐","name":"face with monocle","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["face","with","monocle","classy","fancy","rich","stuffy","wealthy"]}, + {"emoji":"😕","name":"confused face","category":"Smileys & People","version":"1.0","skinTone":false,"keywords":["confused","face","befuddled","confusing","dunno","frown","hm","meh","not","sad","sorry","sure"]}, + {"emoji":"🫤","name":"face with diagonal mouth","category":"Smileys & People","version":"14.0","skinTone":false,"keywords":["face","with","diagonal","mouth","confused","confusion","disappointed","doubt","doubtful","frustrated","frustration","meh","skeptical","unsure","whatever","wtv"]}, + {"emoji":"😟","name":"worried face","category":"Smileys & People","version":"1.0","skinTone":false,"keywords":["worried","face","anxious","butterflies","nerves","nervous","sad","stress","stressed","surprised","worry"]}, + {"emoji":"🙁","name":"slightly frowning face","category":"Smileys & People","version":"1.0","skinTone":false,"keywords":["slightly","frowning","face","frown","sad"]}, + {"emoji":"☹️","name":"frowning face","category":"Smileys & People","version":"0.7","skinTone":false,"keywords":["frowning","face"]}, + {"emoji":"😮","name":"face with open mouth","category":"Smileys & People","version":"1.0","skinTone":false,"keywords":["face","with","open","mouth","believe","forgot","omg","shocked","surprised","sympathy","unbelievable","unreal","whoa","wow","you"]}, + {"emoji":"😯","name":"hushed face","category":"Smileys & People","version":"1.0","skinTone":false,"keywords":["hushed","face","epic","omg","stunned","surprised","whoa","woah"]}, + {"emoji":"😲","name":"astonished face","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["astonished","face","cost","no","omg","shocked","totally","way"]}, + {"emoji":"😳","name":"flushed face","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["flushed","face","amazed","awkward","crazy","dazed","dead","disbelief","embarrassed","geez","heat","hot","impressed","jeez","what","wow"]}, + {"emoji":"🫪","name":"distorted face","category":"Smileys & People","version":"17.0","skinTone":false,"keywords":["distorted","face","anxiety","bloated","panic","shocked","surprised","vulnerable"]}, + {"emoji":"🥺","name":"pleading face","category":"Smileys & People","version":"11.0","skinTone":false,"keywords":["pleading","face","begging","big","eyes","mercy","not","please","pretty","puppy","sad","why"]}, + {"emoji":"🥹","name":"face holding back tears","category":"Smileys & People","version":"14.0","skinTone":false,"keywords":["face","holding","back","tears","admiration","aww","cry","embarrassed","feelings","grateful","gratitude","joy","please","proud","resist","sad"]}, + {"emoji":"😦","name":"frowning face with open mouth","category":"Smileys & People","version":"1.0","skinTone":false,"keywords":["frowning","face","with","open","mouth","caught","frown","guard","scared","scary","surprise","what","wow"]}, + {"emoji":"😧","name":"anguished face","category":"Smileys & People","version":"1.0","skinTone":false,"keywords":["anguished","face","forgot","scared","scary","stressed","surprise","unhappy","what","wow"]}, + {"emoji":"😨","name":"fearful face","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["fearful","face","afraid","anxious","blame","fear","scared","worried"]}, + {"emoji":"😰","name":"anxious face with sweat","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["anxious","face","with","sweat","blue","cold","eek","mouth","nervous","open","rushed","scared","yikes"]}, + {"emoji":"😥","name":"sad but relieved face","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["sad","but","relieved","face","anxious","call","close","complicated","disappointed","not","sweat","time","whew"]}, + {"emoji":"😢","name":"crying face","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["crying","face","awful","cry","feels","miss","sad","tear","triste","unhappy"]}, + {"emoji":"😭","name":"loudly crying face","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["loudly","crying","face","bawling","cry","sad","sob","tear","tears","unhappy"]}, + {"emoji":"😱","name":"face screaming in fear","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["face","screaming","in","fear","epic","fearful","munch","scared","scream","screamer","shocked","surprised","woah"]}, + {"emoji":"😖","name":"confounded face","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["confounded","face","annoyed","confused","cringe","distraught","feels","frustrated","mad","sad"]}, + {"emoji":"😣","name":"persevering face","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["persevering","face","concentrate","concentration","focus","headache","persevere"]}, + {"emoji":"😞","name":"disappointed face","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["disappointed","face","awful","blame","dejected","fail","losing","sad","unhappy"]}, + {"emoji":"😓","name":"downcast face with sweat","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["downcast","face","with","sweat","close","cold","feels","headache","nervous","sad","scared","yikes"]}, + {"emoji":"😩","name":"weary face","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["weary","face","crying","fail","feels","hungry","mad","nooo","sad","sleepy","tired","unhappy"]}, + {"emoji":"😫","name":"tired face","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["tired","face","cost","feels","nap","sad","sneeze"]}, + {"emoji":"🥱","name":"yawning face","category":"Smileys & People","version":"12.0","skinTone":false,"keywords":["yawning","face","bedtime","bored","goodnight","nap","night","sleep","sleepy","tired","whatever","yawn","zzz"]}, + {"emoji":"😤","name":"face with steam from nose","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["face","with","steam","from","nose","anger","angry","feels","fume","fuming","furious","fury","mad","triumph","unhappy","won"]}, + {"emoji":"😡","name":"enraged face","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["enraged","face","anger","angry","feels","mad","maddening","pouting","rage","red","shade","unhappy","upset"]}, + {"emoji":"😠","name":"angry face","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["angry","face","anger","blame","feels","frustrated","mad","maddening","rage","shade","unhappy","upset"]}, + {"emoji":"🤬","name":"face with symbols on mouth","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["face","with","symbols","on","mouth","censor","cursing","cussing","mad","pissed","swearing"]}, + {"emoji":"😈","name":"smiling face with horns","category":"Smileys & People","version":"1.0","skinTone":false,"keywords":["smiling","face","with","horns","demon","devil","evil","fairy","fairytale","fantasy","purple","shade","smile","tale"]}, + {"emoji":"👿","name":"angry face with horns","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["angry","face","with","horns","demon","devil","evil","fairy","fairytale","fantasy","imp","mischievous","purple","shade","tale"]}, + {"emoji":"💀","name":"skull","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["skull","body","dead","death","face","fairy","fairytale","i’m","lmao","monster","tale","yolo"]}, + {"emoji":"☠️","name":"skull and crossbones","category":"Smileys & People","version":"1.0","skinTone":false,"keywords":["skull","and","crossbones"]}, + {"emoji":"💩","name":"pile of poo","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["pile","of","poo","bs","comic","doo","dung","face","fml","monster","poop","smelly","smh","stink","stinks","stinky","turd"]}, + {"emoji":"🤡","name":"clown face","category":"Smileys & People","version":"3.0","skinTone":false,"keywords":["clown","face"]}, + {"emoji":"👹","name":"ogre","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["ogre","creature","devil","face","fairy","fairytale","fantasy","mask","monster","scary","tale"]}, + {"emoji":"👺","name":"goblin","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["goblin","angry","creature","face","fairy","fairytale","fantasy","mask","mean","monster","tale"]}, + {"emoji":"👻","name":"ghost","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["ghost","boo","creature","excited","face","fairy","fairytale","fantasy","halloween","haunting","monster","scary","silly","tale"]}, + {"emoji":"👽","name":"alien","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["alien","creature","extraterrestrial","face","fairy","fairytale","fantasy","monster","space","tale","ufo"]}, + {"emoji":"👾","name":"alien monster","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["alien","monster","creature","extraterrestrial","face","fairy","fairytale","fantasy","game","gamer","games","pixelated","space","tale","ufo"]}, + {"emoji":"🤖","name":"robot","category":"Smileys & People","version":"1.0","skinTone":false,"keywords":["robot","face","monster"]}, + {"emoji":"😺","name":"grinning cat","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["grinning","cat","animal","face","mouth","open","smile","smiling"]}, + {"emoji":"😸","name":"grinning cat with smiling eyes","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["grinning","cat","with","smiling","eyes","animal","eye","face","grin","smile"]}, + {"emoji":"😹","name":"cat with tears of joy","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["cat","with","tears","of","joy","animal","face","laugh","laughing","lol","tear"]}, + {"emoji":"😻","name":"smiling cat with heart-eyes","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["smiling","cat","with","heart-eyes","animal","eye","face","heart","love","smile"]}, + {"emoji":"😼","name":"cat with wry smile","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["cat","with","wry","smile","animal","face","ironic"]}, + {"emoji":"😽","name":"kissing cat","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["kissing","cat","animal","closed","eye","eyes","face","kiss"]}, + {"emoji":"🙀","name":"weary cat","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["weary","cat","animal","face","oh","surprised"]}, + {"emoji":"😿","name":"crying cat","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["crying","cat","animal","cry","face","sad","tear"]}, + {"emoji":"😾","name":"pouting cat","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["pouting","cat","animal","face"]}, + {"emoji":"🙈","name":"see-no-evil monkey","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["see-no-evil","monkey","embarrassed","evil","face","forbidden","forgot","gesture","hide","no","omg","prohibited","scared","secret","smh","watch"]}, + {"emoji":"🙉","name":"hear-no-evil monkey","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["hear-no-evil","monkey","animal","ears","evil","face","forbidden","gesture","hear","listen","no","not","prohibited","secret","shh","tmi"]}, + {"emoji":"🙊","name":"speak-no-evil monkey","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["speak-no-evil","monkey","animal","evil","face","forbidden","gesture","no","not","oops","prohibited","quiet","secret","speak","stealth"]}, + {"emoji":"💌","name":"love letter","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["love","letter","heart","mail","romance","valentine"]}, + {"emoji":"💘","name":"heart with arrow","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["heart","with","arrow","143","adorbs","cupid","date","emotion","ily","love","romance","valentine"]}, + {"emoji":"💝","name":"heart with ribbon","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["heart","with","ribbon","143","anniversary","emotion","ily","kisses","valentine","xoxo"]}, + {"emoji":"💖","name":"sparkling heart","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["sparkling","heart","143","emotion","excited","good","ily","kisses","morning","night","sparkle","xoxo"]}, + {"emoji":"💗","name":"growing heart","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["growing","heart","143","emotion","excited","heartpulse","ily","kisses","muah","nervous","pulse","xoxo"]}, + {"emoji":"💓","name":"beating heart","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["beating","heart","143","cardio","emotion","heartbeat","ily","love","pulsating","pulse"]}, + {"emoji":"💞","name":"revolving hearts","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["revolving","hearts","143","adorbs","anniversary","emotion","heart"]}, + {"emoji":"💕","name":"two hearts","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["two","hearts","143","anniversary","date","dating","emotion","heart","ily","kisses","love","loving","xoxo"]}, + {"emoji":"💟","name":"heart decoration","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["heart","decoration","143","emotion","hearth","purple","white"]}, + {"emoji":"❣️","name":"heart exclamation","category":"Smileys & People","version":"1.0","skinTone":false,"keywords":["heart","exclamation"]}, + {"emoji":"💔","name":"broken heart","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["broken","heart","break","crushed","emotion","heartbroken","lonely","sad"]}, + {"emoji":"❤️‍🔥","name":"heart on fire","category":"Smileys & People","version":"13.1","skinTone":false,"keywords":["heart","on","fire"]}, + {"emoji":"❤️‍🩹","name":"mending heart","category":"Smileys & People","version":"13.1","skinTone":false,"keywords":["mending","heart"]}, + {"emoji":"❤️","name":"red heart","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["red","heart"]}, + {"emoji":"🩷","name":"pink heart","category":"Smileys & People","version":"15.0","skinTone":false,"keywords":["pink","heart","143","adorable","cute","emotion","ily","like","love","special","sweet"]}, + {"emoji":"🧡","name":"orange heart","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["orange","heart","143"]}, + {"emoji":"💛","name":"yellow heart","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["yellow","heart","143","cardiac","emotion","ily","love"]}, + {"emoji":"💚","name":"green heart","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["green","heart","143","emotion","ily","love","romantic"]}, + {"emoji":"💙","name":"blue heart","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["blue","heart","143","emotion","ily","love","romance"]}, + {"emoji":"🩵","name":"light blue heart","category":"Smileys & People","version":"15.0","skinTone":false,"keywords":["light","blue","heart","143","cute","cyan","emotion","ily","like","love","sky","special","teal"]}, + {"emoji":"💜","name":"purple heart","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["purple","heart","143","bestest","emotion","ily","love"]}, + {"emoji":"🤎","name":"brown heart","category":"Smileys & People","version":"12.0","skinTone":false,"keywords":["brown","heart","143"]}, + {"emoji":"🖤","name":"black heart","category":"Smileys & People","version":"3.0","skinTone":false,"keywords":["black","heart","evil","wicked"]}, + {"emoji":"🩶","name":"grey heart","category":"Smileys & People","version":"15.0","skinTone":false,"keywords":["grey","heart","143","emotion","gray","ily","love","silver","slate","special"]}, + {"emoji":"🤍","name":"white heart","category":"Smileys & People","version":"12.0","skinTone":false,"keywords":["white","heart","143"]}, + {"emoji":"💋","name":"kiss mark","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["kiss","mark","dating","emotion","heart","kissing","lips","romance","sexy"]}, + {"emoji":"💯","name":"hundred points","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["hundred","points","100","a+","agree","clearly","definitely","faithful","fleek","full","keep","perfect","point","score","TRUE","truth","yup"]}, + {"emoji":"💢","name":"anger symbol","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["anger","symbol","angry","comic","mad","upset"]}, + {"emoji":"🫯","name":"fight cloud","category":"Smileys & People","version":"17.0","skinTone":false,"keywords":["fight","cloud","argument","brawl","debate","disagreement","ruckus","wrestle"]}, + {"emoji":"💥","name":"collision","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["collision","bomb","boom","collide","comic","explode"]}, + {"emoji":"💫","name":"dizzy","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["dizzy","comic","shining","shooting","star","stars"]}, + {"emoji":"💦","name":"sweat droplets","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["sweat","droplets","comic","drip","droplet","drops","splashing","squirt","water","wet","work","workout"]}, + {"emoji":"💨","name":"dashing away","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["dashing","away","cloud","comic","dash","fart","fast","go","gone","gotta","running","smoke"]}, + {"emoji":"🕳️","name":"hole","category":"Smileys & People","version":"0.7","skinTone":false,"keywords":["hole"]}, + {"emoji":"💬","name":"speech balloon","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["speech","balloon","bubble","comic","dialog","message","sms","talk","text","typing"]}, + {"emoji":"👁️‍🗨️","name":"eye in speech bubble","category":"Smileys & People","version":"2.0","skinTone":false,"keywords":["eye","in","speech","bubble"]}, + {"emoji":"🗨️","name":"left speech bubble","category":"Smileys & People","version":"2.0","skinTone":false,"keywords":["left","speech","bubble"]}, + {"emoji":"🗯️","name":"right anger bubble","category":"Smileys & People","version":"0.7","skinTone":false,"keywords":["right","anger","bubble"]}, + {"emoji":"💭","name":"thought balloon","category":"Smileys & People","version":"1.0","skinTone":false,"keywords":["thought","balloon","bubble","cartoon","cloud","comic","daydream","decisions","dream","idea","invent","invention","realize","think","thoughts","wonder"]}, + {"emoji":"💤","name":"ZZZ","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["ZZZ","comic","good","goodnight","night","sleep","sleeping","sleepy","tired","zzz"]}, + {"emoji":"👋","name":"waving hand","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["waving","hand","bye","cya","g2g","greetings","gtg","hello","hey","hi","later","outtie","ttfn","ttyl","wave","yo","you"]}, + {"emoji":"👋🏻","name":"waving hand: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["waving","hand:","light","skin","tone"]}, + {"emoji":"👋🏼","name":"waving hand: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["waving","hand:","medium-light","skin","tone"]}, + {"emoji":"👋🏽","name":"waving hand: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["waving","hand:","medium","skin","tone"]}, + {"emoji":"👋🏾","name":"waving hand: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["waving","hand:","medium-dark","skin","tone"]}, + {"emoji":"👋🏿","name":"waving hand: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["waving","hand:","dark","skin","tone"]}, + {"emoji":"🤚","name":"raised back of hand","category":"Smileys & People","version":"3.0","skinTone":false,"keywords":["raised","back","of","hand","backhand"]}, + {"emoji":"🤚🏻","name":"raised back of hand: light skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["raised","back","of","hand:","light","skin","tone"]}, + {"emoji":"🤚🏼","name":"raised back of hand: medium-light skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["raised","back","of","hand:","medium-light","skin","tone"]}, + {"emoji":"🤚🏽","name":"raised back of hand: medium skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["raised","back","of","hand:","medium","skin","tone"]}, + {"emoji":"🤚🏾","name":"raised back of hand: medium-dark skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["raised","back","of","hand:","medium-dark","skin","tone"]}, + {"emoji":"🤚🏿","name":"raised back of hand: dark skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["raised","back","of","hand:","dark","skin","tone"]}, + {"emoji":"🖐️","name":"hand with fingers splayed","category":"Smileys & People","version":"0.7","skinTone":false,"keywords":["hand","with","fingers","splayed"]}, + {"emoji":"🖐🏻","name":"hand with fingers splayed: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["hand","with","fingers","splayed:","light","skin","tone"]}, + {"emoji":"🖐🏼","name":"hand with fingers splayed: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["hand","with","fingers","splayed:","medium-light","skin","tone"]}, + {"emoji":"🖐🏽","name":"hand with fingers splayed: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["hand","with","fingers","splayed:","medium","skin","tone"]}, + {"emoji":"🖐🏾","name":"hand with fingers splayed: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["hand","with","fingers","splayed:","medium-dark","skin","tone"]}, + {"emoji":"🖐🏿","name":"hand with fingers splayed: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["hand","with","fingers","splayed:","dark","skin","tone"]}, + {"emoji":"✋","name":"raised hand","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["raised","hand","5","five","high","stop"]}, + {"emoji":"✋🏻","name":"raised hand: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["raised","hand:","light","skin","tone"]}, + {"emoji":"✋🏼","name":"raised hand: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["raised","hand:","medium-light","skin","tone"]}, + {"emoji":"✋🏽","name":"raised hand: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["raised","hand:","medium","skin","tone"]}, + {"emoji":"✋🏾","name":"raised hand: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["raised","hand:","medium-dark","skin","tone"]}, + {"emoji":"✋🏿","name":"raised hand: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["raised","hand:","dark","skin","tone"]}, + {"emoji":"🖖","name":"vulcan salute","category":"Smileys & People","version":"1.0","skinTone":false,"keywords":["vulcan","salute","finger","hand","hands","Vulcan"]}, + {"emoji":"🖖🏻","name":"vulcan salute: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["vulcan","salute:","light","skin","tone"]}, + {"emoji":"🖖🏼","name":"vulcan salute: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["vulcan","salute:","medium-light","skin","tone"]}, + {"emoji":"🖖🏽","name":"vulcan salute: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["vulcan","salute:","medium","skin","tone"]}, + {"emoji":"🖖🏾","name":"vulcan salute: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["vulcan","salute:","medium-dark","skin","tone"]}, + {"emoji":"🖖🏿","name":"vulcan salute: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["vulcan","salute:","dark","skin","tone"]}, + {"emoji":"🫱","name":"rightwards hand","category":"Smileys & People","version":"14.0","skinTone":false,"keywords":["rightwards","hand","handshake","hold","reach","right","rightward","shake"]}, + {"emoji":"🫱🏻","name":"rightwards hand: light skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["rightwards","hand:","light","skin","tone"]}, + {"emoji":"🫱🏼","name":"rightwards hand: medium-light skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["rightwards","hand:","medium-light","skin","tone"]}, + {"emoji":"🫱🏽","name":"rightwards hand: medium skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["rightwards","hand:","medium","skin","tone"]}, + {"emoji":"🫱🏾","name":"rightwards hand: medium-dark skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["rightwards","hand:","medium-dark","skin","tone"]}, + {"emoji":"🫱🏿","name":"rightwards hand: dark skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["rightwards","hand:","dark","skin","tone"]}, + {"emoji":"🫲","name":"leftwards hand","category":"Smileys & People","version":"14.0","skinTone":false,"keywords":["leftwards","hand","handshake","hold","left","leftward","reach","shake"]}, + {"emoji":"🫲🏻","name":"leftwards hand: light skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["leftwards","hand:","light","skin","tone"]}, + {"emoji":"🫲🏼","name":"leftwards hand: medium-light skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["leftwards","hand:","medium-light","skin","tone"]}, + {"emoji":"🫲🏽","name":"leftwards hand: medium skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["leftwards","hand:","medium","skin","tone"]}, + {"emoji":"🫲🏾","name":"leftwards hand: medium-dark skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["leftwards","hand:","medium-dark","skin","tone"]}, + {"emoji":"🫲🏿","name":"leftwards hand: dark skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["leftwards","hand:","dark","skin","tone"]}, + {"emoji":"🫳","name":"palm down hand","category":"Smileys & People","version":"14.0","skinTone":false,"keywords":["palm","down","hand","dismiss","drop","dropped","pick","shoo","up"]}, + {"emoji":"🫳🏻","name":"palm down hand: light skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["palm","down","hand:","light","skin","tone"]}, + {"emoji":"🫳🏼","name":"palm down hand: medium-light skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["palm","down","hand:","medium-light","skin","tone"]}, + {"emoji":"🫳🏽","name":"palm down hand: medium skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["palm","down","hand:","medium","skin","tone"]}, + {"emoji":"🫳🏾","name":"palm down hand: medium-dark skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["palm","down","hand:","medium-dark","skin","tone"]}, + {"emoji":"🫳🏿","name":"palm down hand: dark skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["palm","down","hand:","dark","skin","tone"]}, + {"emoji":"🫴","name":"palm up hand","category":"Smileys & People","version":"14.0","skinTone":false,"keywords":["palm","up","hand","beckon","catch","come","hold","know","lift","me","offer","tell"]}, + {"emoji":"🫴🏻","name":"palm up hand: light skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["palm","up","hand:","light","skin","tone"]}, + {"emoji":"🫴🏼","name":"palm up hand: medium-light skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["palm","up","hand:","medium-light","skin","tone"]}, + {"emoji":"🫴🏽","name":"palm up hand: medium skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["palm","up","hand:","medium","skin","tone"]}, + {"emoji":"🫴🏾","name":"palm up hand: medium-dark skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["palm","up","hand:","medium-dark","skin","tone"]}, + {"emoji":"🫴🏿","name":"palm up hand: dark skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["palm","up","hand:","dark","skin","tone"]}, + {"emoji":"🫷","name":"leftwards pushing hand","category":"Smileys & People","version":"15.0","skinTone":false,"keywords":["leftwards","pushing","hand","block","five","halt","high","hold","leftward","pause","push","refuse","slap","stop","wait"]}, + {"emoji":"🫷🏻","name":"leftwards pushing hand: light skin tone","category":"Smileys & People","version":"15.0","skinTone":true,"keywords":["leftwards","pushing","hand:","light","skin","tone"]}, + {"emoji":"🫷🏼","name":"leftwards pushing hand: medium-light skin tone","category":"Smileys & People","version":"15.0","skinTone":true,"keywords":["leftwards","pushing","hand:","medium-light","skin","tone"]}, + {"emoji":"🫷🏽","name":"leftwards pushing hand: medium skin tone","category":"Smileys & People","version":"15.0","skinTone":true,"keywords":["leftwards","pushing","hand:","medium","skin","tone"]}, + {"emoji":"🫷🏾","name":"leftwards pushing hand: medium-dark skin tone","category":"Smileys & People","version":"15.0","skinTone":true,"keywords":["leftwards","pushing","hand:","medium-dark","skin","tone"]}, + {"emoji":"🫷🏿","name":"leftwards pushing hand: dark skin tone","category":"Smileys & People","version":"15.0","skinTone":true,"keywords":["leftwards","pushing","hand:","dark","skin","tone"]}, + {"emoji":"🫸","name":"rightwards pushing hand","category":"Smileys & People","version":"15.0","skinTone":false,"keywords":["rightwards","pushing","hand","block","five","halt","high","hold","pause","push","refuse","rightward","slap","stop","wait"]}, + {"emoji":"🫸🏻","name":"rightwards pushing hand: light skin tone","category":"Smileys & People","version":"15.0","skinTone":true,"keywords":["rightwards","pushing","hand:","light","skin","tone"]}, + {"emoji":"🫸🏼","name":"rightwards pushing hand: medium-light skin tone","category":"Smileys & People","version":"15.0","skinTone":true,"keywords":["rightwards","pushing","hand:","medium-light","skin","tone"]}, + {"emoji":"🫸🏽","name":"rightwards pushing hand: medium skin tone","category":"Smileys & People","version":"15.0","skinTone":true,"keywords":["rightwards","pushing","hand:","medium","skin","tone"]}, + {"emoji":"🫸🏾","name":"rightwards pushing hand: medium-dark skin tone","category":"Smileys & People","version":"15.0","skinTone":true,"keywords":["rightwards","pushing","hand:","medium-dark","skin","tone"]}, + {"emoji":"🫸🏿","name":"rightwards pushing hand: dark skin tone","category":"Smileys & People","version":"15.0","skinTone":true,"keywords":["rightwards","pushing","hand:","dark","skin","tone"]}, + {"emoji":"👌","name":"OK hand","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["OK","hand","awesome","bet","dope","fleek","fosho","got","gotcha","legit","okay","pinch","rad","sure","sweet","three"]}, + {"emoji":"👌🏻","name":"OK hand: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["OK","hand:","light","skin","tone"]}, + {"emoji":"👌🏼","name":"OK hand: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["OK","hand:","medium-light","skin","tone"]}, + {"emoji":"👌🏽","name":"OK hand: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["OK","hand:","medium","skin","tone"]}, + {"emoji":"👌🏾","name":"OK hand: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["OK","hand:","medium-dark","skin","tone"]}, + {"emoji":"👌🏿","name":"OK hand: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["OK","hand:","dark","skin","tone"]}, + {"emoji":"🤌","name":"pinched fingers","category":"Smileys & People","version":"13.0","skinTone":false,"keywords":["pinched","fingers","gesture","hand","hold","huh","interrogation","patience","relax","sarcastic","ugh","what","zip"]}, + {"emoji":"🤌🏻","name":"pinched fingers: light skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["pinched","fingers:","light","skin","tone"]}, + {"emoji":"🤌🏼","name":"pinched fingers: medium-light skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["pinched","fingers:","medium-light","skin","tone"]}, + {"emoji":"🤌🏽","name":"pinched fingers: medium skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["pinched","fingers:","medium","skin","tone"]}, + {"emoji":"🤌🏾","name":"pinched fingers: medium-dark skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["pinched","fingers:","medium-dark","skin","tone"]}, + {"emoji":"🤌🏿","name":"pinched fingers: dark skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["pinched","fingers:","dark","skin","tone"]}, + {"emoji":"🤏","name":"pinching hand","category":"Smileys & People","version":"12.0","skinTone":false,"keywords":["pinching","hand","amount","bit","fingers","little","small","sort"]}, + {"emoji":"🤏🏻","name":"pinching hand: light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["pinching","hand:","light","skin","tone"]}, + {"emoji":"🤏🏼","name":"pinching hand: medium-light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["pinching","hand:","medium-light","skin","tone"]}, + {"emoji":"🤏🏽","name":"pinching hand: medium skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["pinching","hand:","medium","skin","tone"]}, + {"emoji":"🤏🏾","name":"pinching hand: medium-dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["pinching","hand:","medium-dark","skin","tone"]}, + {"emoji":"🤏🏿","name":"pinching hand: dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["pinching","hand:","dark","skin","tone"]}, + {"emoji":"✌️","name":"victory hand","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["victory","hand"]}, + {"emoji":"✌🏻","name":"victory hand: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["victory","hand:","light","skin","tone"]}, + {"emoji":"✌🏼","name":"victory hand: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["victory","hand:","medium-light","skin","tone"]}, + {"emoji":"✌🏽","name":"victory hand: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["victory","hand:","medium","skin","tone"]}, + {"emoji":"✌🏾","name":"victory hand: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["victory","hand:","medium-dark","skin","tone"]}, + {"emoji":"✌🏿","name":"victory hand: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["victory","hand:","dark","skin","tone"]}, + {"emoji":"🤞","name":"crossed fingers","category":"Smileys & People","version":"3.0","skinTone":false,"keywords":["crossed","fingers","cross","finger","hand","luck"]}, + {"emoji":"🤞🏻","name":"crossed fingers: light skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["crossed","fingers:","light","skin","tone"]}, + {"emoji":"🤞🏼","name":"crossed fingers: medium-light skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["crossed","fingers:","medium-light","skin","tone"]}, + {"emoji":"🤞🏽","name":"crossed fingers: medium skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["crossed","fingers:","medium","skin","tone"]}, + {"emoji":"🤞🏾","name":"crossed fingers: medium-dark skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["crossed","fingers:","medium-dark","skin","tone"]}, + {"emoji":"🤞🏿","name":"crossed fingers: dark skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["crossed","fingers:","dark","skin","tone"]}, + {"emoji":"🫰","name":"hand with index finger and thumb crossed","category":"Smileys & People","version":"14.0","skinTone":false,"keywords":["hand","with","index","finger","and","thumb","crossed","<3","expensive","heart","love","money","snap"]}, + {"emoji":"🫰🏻","name":"hand with index finger and thumb crossed: light skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["hand","with","index","finger","and","thumb","crossed:","light","skin","tone"]}, + {"emoji":"🫰🏼","name":"hand with index finger and thumb crossed: medium-light skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["hand","with","index","finger","and","thumb","crossed:","medium-light","skin","tone"]}, + {"emoji":"🫰🏽","name":"hand with index finger and thumb crossed: medium skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["hand","with","index","finger","and","thumb","crossed:","medium","skin","tone"]}, + {"emoji":"🫰🏾","name":"hand with index finger and thumb crossed: medium-dark skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["hand","with","index","finger","and","thumb","crossed:","medium-dark","skin","tone"]}, + {"emoji":"🫰🏿","name":"hand with index finger and thumb crossed: dark skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["hand","with","index","finger","and","thumb","crossed:","dark","skin","tone"]}, + {"emoji":"🤟","name":"love-you gesture","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["love-you","gesture","fingers","hand","ILY","love","three","you"]}, + {"emoji":"🤟🏻","name":"love-you gesture: light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["love-you","gesture:","light","skin","tone"]}, + {"emoji":"🤟🏼","name":"love-you gesture: medium-light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["love-you","gesture:","medium-light","skin","tone"]}, + {"emoji":"🤟🏽","name":"love-you gesture: medium skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["love-you","gesture:","medium","skin","tone"]}, + {"emoji":"🤟🏾","name":"love-you gesture: medium-dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["love-you","gesture:","medium-dark","skin","tone"]}, + {"emoji":"🤟🏿","name":"love-you gesture: dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["love-you","gesture:","dark","skin","tone"]}, + {"emoji":"🤘","name":"sign of the horns","category":"Smileys & People","version":"1.0","skinTone":false,"keywords":["sign","of","the","horns","finger","hand","rock-on"]}, + {"emoji":"🤘🏻","name":"sign of the horns: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["sign","of","the","horns:","light","skin","tone"]}, + {"emoji":"🤘🏼","name":"sign of the horns: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["sign","of","the","horns:","medium-light","skin","tone"]}, + {"emoji":"🤘🏽","name":"sign of the horns: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["sign","of","the","horns:","medium","skin","tone"]}, + {"emoji":"🤘🏾","name":"sign of the horns: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["sign","of","the","horns:","medium-dark","skin","tone"]}, + {"emoji":"🤘🏿","name":"sign of the horns: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["sign","of","the","horns:","dark","skin","tone"]}, + {"emoji":"🤙","name":"call me hand","category":"Smileys & People","version":"3.0","skinTone":false,"keywords":["call","me","hand","hang","loose","Shaka"]}, + {"emoji":"🤙🏻","name":"call me hand: light skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["call","me","hand:","light","skin","tone"]}, + {"emoji":"🤙🏼","name":"call me hand: medium-light skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["call","me","hand:","medium-light","skin","tone"]}, + {"emoji":"🤙🏽","name":"call me hand: medium skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["call","me","hand:","medium","skin","tone"]}, + {"emoji":"🤙🏾","name":"call me hand: medium-dark skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["call","me","hand:","medium-dark","skin","tone"]}, + {"emoji":"🤙🏿","name":"call me hand: dark skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["call","me","hand:","dark","skin","tone"]}, + {"emoji":"👈","name":"backhand index pointing left","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["backhand","index","pointing","left","finger","hand","point"]}, + {"emoji":"👈🏻","name":"backhand index pointing left: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["backhand","index","pointing","left:","light","skin","tone"]}, + {"emoji":"👈🏼","name":"backhand index pointing left: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["backhand","index","pointing","left:","medium-light","skin","tone"]}, + {"emoji":"👈🏽","name":"backhand index pointing left: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["backhand","index","pointing","left:","medium","skin","tone"]}, + {"emoji":"👈🏾","name":"backhand index pointing left: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["backhand","index","pointing","left:","medium-dark","skin","tone"]}, + {"emoji":"👈🏿","name":"backhand index pointing left: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["backhand","index","pointing","left:","dark","skin","tone"]}, + {"emoji":"👉","name":"backhand index pointing right","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["backhand","index","pointing","right","finger","hand","point"]}, + {"emoji":"👉🏻","name":"backhand index pointing right: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["backhand","index","pointing","right:","light","skin","tone"]}, + {"emoji":"👉🏼","name":"backhand index pointing right: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["backhand","index","pointing","right:","medium-light","skin","tone"]}, + {"emoji":"👉🏽","name":"backhand index pointing right: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["backhand","index","pointing","right:","medium","skin","tone"]}, + {"emoji":"👉🏾","name":"backhand index pointing right: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["backhand","index","pointing","right:","medium-dark","skin","tone"]}, + {"emoji":"👉🏿","name":"backhand index pointing right: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["backhand","index","pointing","right:","dark","skin","tone"]}, + {"emoji":"👆","name":"backhand index pointing up","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["backhand","index","pointing","up","finger","hand","point"]}, + {"emoji":"👆🏻","name":"backhand index pointing up: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["backhand","index","pointing","up:","light","skin","tone"]}, + {"emoji":"👆🏼","name":"backhand index pointing up: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["backhand","index","pointing","up:","medium-light","skin","tone"]}, + {"emoji":"👆🏽","name":"backhand index pointing up: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["backhand","index","pointing","up:","medium","skin","tone"]}, + {"emoji":"👆🏾","name":"backhand index pointing up: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["backhand","index","pointing","up:","medium-dark","skin","tone"]}, + {"emoji":"👆🏿","name":"backhand index pointing up: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["backhand","index","pointing","up:","dark","skin","tone"]}, + {"emoji":"🖕","name":"middle finger","category":"Smileys & People","version":"1.0","skinTone":false,"keywords":["middle","finger","hand"]}, + {"emoji":"🖕🏻","name":"middle finger: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["middle","finger:","light","skin","tone"]}, + {"emoji":"🖕🏼","name":"middle finger: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["middle","finger:","medium-light","skin","tone"]}, + {"emoji":"🖕🏽","name":"middle finger: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["middle","finger:","medium","skin","tone"]}, + {"emoji":"🖕🏾","name":"middle finger: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["middle","finger:","medium-dark","skin","tone"]}, + {"emoji":"🖕🏿","name":"middle finger: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["middle","finger:","dark","skin","tone"]}, + {"emoji":"👇","name":"backhand index pointing down","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["backhand","index","pointing","down","finger","hand","point"]}, + {"emoji":"👇🏻","name":"backhand index pointing down: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["backhand","index","pointing","down:","light","skin","tone"]}, + {"emoji":"👇🏼","name":"backhand index pointing down: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["backhand","index","pointing","down:","medium-light","skin","tone"]}, + {"emoji":"👇🏽","name":"backhand index pointing down: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["backhand","index","pointing","down:","medium","skin","tone"]}, + {"emoji":"👇🏾","name":"backhand index pointing down: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["backhand","index","pointing","down:","medium-dark","skin","tone"]}, + {"emoji":"👇🏿","name":"backhand index pointing down: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["backhand","index","pointing","down:","dark","skin","tone"]}, + {"emoji":"☝️","name":"index pointing up","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["index","pointing","up"]}, + {"emoji":"☝🏻","name":"index pointing up: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["index","pointing","up:","light","skin","tone"]}, + {"emoji":"☝🏼","name":"index pointing up: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["index","pointing","up:","medium-light","skin","tone"]}, + {"emoji":"☝🏽","name":"index pointing up: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["index","pointing","up:","medium","skin","tone"]}, + {"emoji":"☝🏾","name":"index pointing up: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["index","pointing","up:","medium-dark","skin","tone"]}, + {"emoji":"☝🏿","name":"index pointing up: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["index","pointing","up:","dark","skin","tone"]}, + {"emoji":"🫵","name":"index pointing at the viewer","category":"Smileys & People","version":"14.0","skinTone":false,"keywords":["index","pointing","at","the","viewer","finger","hand","poke","you"]}, + {"emoji":"🫵🏻","name":"index pointing at the viewer: light skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["index","pointing","at","the","viewer:","light","skin","tone"]}, + {"emoji":"🫵🏼","name":"index pointing at the viewer: medium-light skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["index","pointing","at","the","viewer:","medium-light","skin","tone"]}, + {"emoji":"🫵🏽","name":"index pointing at the viewer: medium skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["index","pointing","at","the","viewer:","medium","skin","tone"]}, + {"emoji":"🫵🏾","name":"index pointing at the viewer: medium-dark skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["index","pointing","at","the","viewer:","medium-dark","skin","tone"]}, + {"emoji":"🫵🏿","name":"index pointing at the viewer: dark skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["index","pointing","at","the","viewer:","dark","skin","tone"]}, + {"emoji":"👍","name":"thumbs up","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["thumbs","up","+1","good","hand","like","thumb","yes"]}, + {"emoji":"👍🏻","name":"thumbs up: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["thumbs","up:","light","skin","tone"]}, + {"emoji":"👍🏼","name":"thumbs up: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["thumbs","up:","medium-light","skin","tone"]}, + {"emoji":"👍🏽","name":"thumbs up: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["thumbs","up:","medium","skin","tone"]}, + {"emoji":"👍🏾","name":"thumbs up: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["thumbs","up:","medium-dark","skin","tone"]}, + {"emoji":"👍🏿","name":"thumbs up: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["thumbs","up:","dark","skin","tone"]}, + {"emoji":"👎","name":"thumbs down","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["thumbs","down","-1","bad","dislike","good","hand","no","nope","thumb"]}, + {"emoji":"👎🏻","name":"thumbs down: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["thumbs","down:","light","skin","tone"]}, + {"emoji":"👎🏼","name":"thumbs down: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["thumbs","down:","medium-light","skin","tone"]}, + {"emoji":"👎🏽","name":"thumbs down: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["thumbs","down:","medium","skin","tone"]}, + {"emoji":"👎🏾","name":"thumbs down: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["thumbs","down:","medium-dark","skin","tone"]}, + {"emoji":"👎🏿","name":"thumbs down: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["thumbs","down:","dark","skin","tone"]}, + {"emoji":"✊","name":"raised fist","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["raised","fist","clenched","hand","punch","solidarity"]}, + {"emoji":"✊🏻","name":"raised fist: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["raised","fist:","light","skin","tone"]}, + {"emoji":"✊🏼","name":"raised fist: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["raised","fist:","medium-light","skin","tone"]}, + {"emoji":"✊🏽","name":"raised fist: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["raised","fist:","medium","skin","tone"]}, + {"emoji":"✊🏾","name":"raised fist: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["raised","fist:","medium-dark","skin","tone"]}, + {"emoji":"✊🏿","name":"raised fist: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["raised","fist:","dark","skin","tone"]}, + {"emoji":"👊","name":"oncoming fist","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["oncoming","fist","absolutely","agree","boom","bro","bruh","bump","clenched","correct","hand","knuckle","pound","punch","rock","ttyl"]}, + {"emoji":"👊🏻","name":"oncoming fist: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["oncoming","fist:","light","skin","tone"]}, + {"emoji":"👊🏼","name":"oncoming fist: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["oncoming","fist:","medium-light","skin","tone"]}, + {"emoji":"👊🏽","name":"oncoming fist: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["oncoming","fist:","medium","skin","tone"]}, + {"emoji":"👊🏾","name":"oncoming fist: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["oncoming","fist:","medium-dark","skin","tone"]}, + {"emoji":"👊🏿","name":"oncoming fist: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["oncoming","fist:","dark","skin","tone"]}, + {"emoji":"🤛","name":"left-facing fist","category":"Smileys & People","version":"3.0","skinTone":false,"keywords":["left-facing","fist","leftwards"]}, + {"emoji":"🤛🏻","name":"left-facing fist: light skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["left-facing","fist:","light","skin","tone"]}, + {"emoji":"🤛🏼","name":"left-facing fist: medium-light skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["left-facing","fist:","medium-light","skin","tone"]}, + {"emoji":"🤛🏽","name":"left-facing fist: medium skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["left-facing","fist:","medium","skin","tone"]}, + {"emoji":"🤛🏾","name":"left-facing fist: medium-dark skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["left-facing","fist:","medium-dark","skin","tone"]}, + {"emoji":"🤛🏿","name":"left-facing fist: dark skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["left-facing","fist:","dark","skin","tone"]}, + {"emoji":"🤜","name":"right-facing fist","category":"Smileys & People","version":"3.0","skinTone":false,"keywords":["right-facing","fist","rightwards"]}, + {"emoji":"🤜🏻","name":"right-facing fist: light skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["right-facing","fist:","light","skin","tone"]}, + {"emoji":"🤜🏼","name":"right-facing fist: medium-light skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["right-facing","fist:","medium-light","skin","tone"]}, + {"emoji":"🤜🏽","name":"right-facing fist: medium skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["right-facing","fist:","medium","skin","tone"]}, + {"emoji":"🤜🏾","name":"right-facing fist: medium-dark skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["right-facing","fist:","medium-dark","skin","tone"]}, + {"emoji":"🤜🏿","name":"right-facing fist: dark skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["right-facing","fist:","dark","skin","tone"]}, + {"emoji":"👏","name":"clapping hands","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["clapping","hands","applause","approval","awesome","clap","congrats","congratulations","excited","good","great","hand","homie","job","nice","prayed","well","yay"]}, + {"emoji":"👏🏻","name":"clapping hands: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["clapping","hands:","light","skin","tone"]}, + {"emoji":"👏🏼","name":"clapping hands: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["clapping","hands:","medium-light","skin","tone"]}, + {"emoji":"👏🏽","name":"clapping hands: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["clapping","hands:","medium","skin","tone"]}, + {"emoji":"👏🏾","name":"clapping hands: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["clapping","hands:","medium-dark","skin","tone"]}, + {"emoji":"👏🏿","name":"clapping hands: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["clapping","hands:","dark","skin","tone"]}, + {"emoji":"🙌","name":"raising hands","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["raising","hands","celebration","gesture","hand","hooray","praise","raised"]}, + {"emoji":"🙌🏻","name":"raising hands: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["raising","hands:","light","skin","tone"]}, + {"emoji":"🙌🏼","name":"raising hands: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["raising","hands:","medium-light","skin","tone"]}, + {"emoji":"🙌🏽","name":"raising hands: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["raising","hands:","medium","skin","tone"]}, + {"emoji":"🙌🏾","name":"raising hands: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["raising","hands:","medium-dark","skin","tone"]}, + {"emoji":"🙌🏿","name":"raising hands: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["raising","hands:","dark","skin","tone"]}, + {"emoji":"🫶","name":"heart hands","category":"Smileys & People","version":"14.0","skinTone":false,"keywords":["heart","hands","<3","love","you"]}, + {"emoji":"🫶🏻","name":"heart hands: light skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["heart","hands:","light","skin","tone"]}, + {"emoji":"🫶🏼","name":"heart hands: medium-light skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["heart","hands:","medium-light","skin","tone"]}, + {"emoji":"🫶🏽","name":"heart hands: medium skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["heart","hands:","medium","skin","tone"]}, + {"emoji":"🫶🏾","name":"heart hands: medium-dark skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["heart","hands:","medium-dark","skin","tone"]}, + {"emoji":"🫶🏿","name":"heart hands: dark skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["heart","hands:","dark","skin","tone"]}, + {"emoji":"👐","name":"open hands","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["open","hands","hand","hug","jazz","swerve"]}, + {"emoji":"👐🏻","name":"open hands: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["open","hands:","light","skin","tone"]}, + {"emoji":"👐🏼","name":"open hands: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["open","hands:","medium-light","skin","tone"]}, + {"emoji":"👐🏽","name":"open hands: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["open","hands:","medium","skin","tone"]}, + {"emoji":"👐🏾","name":"open hands: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["open","hands:","medium-dark","skin","tone"]}, + {"emoji":"👐🏿","name":"open hands: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["open","hands:","dark","skin","tone"]}, + {"emoji":"🤲","name":"palms up together","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["palms","up","together","cupped","dua","hands","pray","prayer","wish"]}, + {"emoji":"🤲🏻","name":"palms up together: light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["palms","up","together:","light","skin","tone"]}, + {"emoji":"🤲🏼","name":"palms up together: medium-light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["palms","up","together:","medium-light","skin","tone"]}, + {"emoji":"🤲🏽","name":"palms up together: medium skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["palms","up","together:","medium","skin","tone"]}, + {"emoji":"🤲🏾","name":"palms up together: medium-dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["palms","up","together:","medium-dark","skin","tone"]}, + {"emoji":"🤲🏿","name":"palms up together: dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["palms","up","together:","dark","skin","tone"]}, + {"emoji":"🤝","name":"handshake","category":"Smileys & People","version":"3.0","skinTone":false,"keywords":["handshake","agreement","deal","hand","meeting","shake"]}, + {"emoji":"🤝🏻","name":"handshake: light skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["handshake:","light","skin","tone"]}, + {"emoji":"🤝🏼","name":"handshake: medium-light skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["handshake:","medium-light","skin","tone"]}, + {"emoji":"🤝🏽","name":"handshake: medium skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["handshake:","medium","skin","tone"]}, + {"emoji":"🤝🏾","name":"handshake: medium-dark skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["handshake:","medium-dark","skin","tone"]}, + {"emoji":"🤝🏿","name":"handshake: dark skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["handshake:","dark","skin","tone"]}, + {"emoji":"🫱🏻‍🫲🏼","name":"handshake: light skin tone, medium-light skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["handshake:","light","skin","tone,","medium-light","tone"]}, + {"emoji":"🫱🏻‍🫲🏽","name":"handshake: light skin tone, medium skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["handshake:","light","skin","tone,","medium","tone"]}, + {"emoji":"🫱🏻‍🫲🏾","name":"handshake: light skin tone, medium-dark skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["handshake:","light","skin","tone,","medium-dark","tone"]}, + {"emoji":"🫱🏻‍🫲🏿","name":"handshake: light skin tone, dark skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["handshake:","light","skin","tone,","dark","tone"]}, + {"emoji":"🫱🏼‍🫲🏻","name":"handshake: medium-light skin tone, light skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["handshake:","medium-light","skin","tone,","light","tone"]}, + {"emoji":"🫱🏼‍🫲🏽","name":"handshake: medium-light skin tone, medium skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["handshake:","medium-light","skin","tone,","medium","tone"]}, + {"emoji":"🫱🏼‍🫲🏾","name":"handshake: medium-light skin tone, medium-dark skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["handshake:","medium-light","skin","tone,","medium-dark","tone"]}, + {"emoji":"🫱🏼‍🫲🏿","name":"handshake: medium-light skin tone, dark skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["handshake:","medium-light","skin","tone,","dark","tone"]}, + {"emoji":"🫱🏽‍🫲🏻","name":"handshake: medium skin tone, light skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["handshake:","medium","skin","tone,","light","tone"]}, + {"emoji":"🫱🏽‍🫲🏼","name":"handshake: medium skin tone, medium-light skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["handshake:","medium","skin","tone,","medium-light","tone"]}, + {"emoji":"🫱🏽‍🫲🏾","name":"handshake: medium skin tone, medium-dark skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["handshake:","medium","skin","tone,","medium-dark","tone"]}, + {"emoji":"🫱🏽‍🫲🏿","name":"handshake: medium skin tone, dark skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["handshake:","medium","skin","tone,","dark","tone"]}, + {"emoji":"🫱🏾‍🫲🏻","name":"handshake: medium-dark skin tone, light skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["handshake:","medium-dark","skin","tone,","light","tone"]}, + {"emoji":"🫱🏾‍🫲🏼","name":"handshake: medium-dark skin tone, medium-light skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["handshake:","medium-dark","skin","tone,","medium-light","tone"]}, + {"emoji":"🫱🏾‍🫲🏽","name":"handshake: medium-dark skin tone, medium skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["handshake:","medium-dark","skin","tone,","medium","tone"]}, + {"emoji":"🫱🏾‍🫲🏿","name":"handshake: medium-dark skin tone, dark skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["handshake:","medium-dark","skin","tone,","dark","tone"]}, + {"emoji":"🫱🏿‍🫲🏻","name":"handshake: dark skin tone, light skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["handshake:","dark","skin","tone,","light","tone"]}, + {"emoji":"🫱🏿‍🫲🏼","name":"handshake: dark skin tone, medium-light skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["handshake:","dark","skin","tone,","medium-light","tone"]}, + {"emoji":"🫱🏿‍🫲🏽","name":"handshake: dark skin tone, medium skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["handshake:","dark","skin","tone,","medium","tone"]}, + {"emoji":"🫱🏿‍🫲🏾","name":"handshake: dark skin tone, medium-dark skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["handshake:","dark","skin","tone,","medium-dark","tone"]}, + {"emoji":"🙏","name":"folded hands","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["folded","hands","appreciate","ask","beg","blessed","bow","cmon","five","gesture","hand","high","please","pray","thanks","thx"]}, + {"emoji":"🙏🏻","name":"folded hands: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["folded","hands:","light","skin","tone"]}, + {"emoji":"🙏🏼","name":"folded hands: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["folded","hands:","medium-light","skin","tone"]}, + {"emoji":"🙏🏽","name":"folded hands: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["folded","hands:","medium","skin","tone"]}, + {"emoji":"🙏🏾","name":"folded hands: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["folded","hands:","medium-dark","skin","tone"]}, + {"emoji":"🙏🏿","name":"folded hands: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["folded","hands:","dark","skin","tone"]}, + {"emoji":"✍️","name":"writing hand","category":"Smileys & People","version":"0.7","skinTone":false,"keywords":["writing","hand"]}, + {"emoji":"✍🏻","name":"writing hand: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["writing","hand:","light","skin","tone"]}, + {"emoji":"✍🏼","name":"writing hand: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["writing","hand:","medium-light","skin","tone"]}, + {"emoji":"✍🏽","name":"writing hand: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["writing","hand:","medium","skin","tone"]}, + {"emoji":"✍🏾","name":"writing hand: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["writing","hand:","medium-dark","skin","tone"]}, + {"emoji":"✍🏿","name":"writing hand: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["writing","hand:","dark","skin","tone"]}, + {"emoji":"💅","name":"nail polish","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["nail","polish","bored","care","cosmetics","done","makeup","manicure","whatever"]}, + {"emoji":"💅🏻","name":"nail polish: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["nail","polish:","light","skin","tone"]}, + {"emoji":"💅🏼","name":"nail polish: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["nail","polish:","medium-light","skin","tone"]}, + {"emoji":"💅🏽","name":"nail polish: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["nail","polish:","medium","skin","tone"]}, + {"emoji":"💅🏾","name":"nail polish: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["nail","polish:","medium-dark","skin","tone"]}, + {"emoji":"💅🏿","name":"nail polish: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["nail","polish:","dark","skin","tone"]}, + {"emoji":"🤳","name":"selfie","category":"Smileys & People","version":"3.0","skinTone":false,"keywords":["selfie","camera","phone"]}, + {"emoji":"🤳🏻","name":"selfie: light skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["selfie:","light","skin","tone"]}, + {"emoji":"🤳🏼","name":"selfie: medium-light skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["selfie:","medium-light","skin","tone"]}, + {"emoji":"🤳🏽","name":"selfie: medium skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["selfie:","medium","skin","tone"]}, + {"emoji":"🤳🏾","name":"selfie: medium-dark skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["selfie:","medium-dark","skin","tone"]}, + {"emoji":"🤳🏿","name":"selfie: dark skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["selfie:","dark","skin","tone"]}, + {"emoji":"💪","name":"flexed biceps","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["flexed","biceps","arm","beast","bench","bodybuilder","bro","curls","flex","gains","gym","jacked","muscle","press","ripped","strong","weightlift"]}, + {"emoji":"💪🏻","name":"flexed biceps: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["flexed","biceps:","light","skin","tone"]}, + {"emoji":"💪🏼","name":"flexed biceps: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["flexed","biceps:","medium-light","skin","tone"]}, + {"emoji":"💪🏽","name":"flexed biceps: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["flexed","biceps:","medium","skin","tone"]}, + {"emoji":"💪🏾","name":"flexed biceps: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["flexed","biceps:","medium-dark","skin","tone"]}, + {"emoji":"💪🏿","name":"flexed biceps: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["flexed","biceps:","dark","skin","tone"]}, + {"emoji":"🦾","name":"mechanical arm","category":"Smileys & People","version":"12.0","skinTone":false,"keywords":["mechanical","arm","accessibility","prosthetic"]}, + {"emoji":"🦿","name":"mechanical leg","category":"Smileys & People","version":"12.0","skinTone":false,"keywords":["mechanical","leg","accessibility","prosthetic"]}, + {"emoji":"🦵","name":"leg","category":"Smileys & People","version":"11.0","skinTone":false,"keywords":["leg","bent","foot","kick","knee","limb"]}, + {"emoji":"🦵🏻","name":"leg: light skin tone","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["leg:","light","skin","tone"]}, + {"emoji":"🦵🏼","name":"leg: medium-light skin tone","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["leg:","medium-light","skin","tone"]}, + {"emoji":"🦵🏽","name":"leg: medium skin tone","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["leg:","medium","skin","tone"]}, + {"emoji":"🦵🏾","name":"leg: medium-dark skin tone","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["leg:","medium-dark","skin","tone"]}, + {"emoji":"🦵🏿","name":"leg: dark skin tone","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["leg:","dark","skin","tone"]}, + {"emoji":"🦶","name":"foot","category":"Smileys & People","version":"11.0","skinTone":false,"keywords":["foot","ankle","feet","kick","stomp"]}, + {"emoji":"🦶🏻","name":"foot: light skin tone","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["foot:","light","skin","tone"]}, + {"emoji":"🦶🏼","name":"foot: medium-light skin tone","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["foot:","medium-light","skin","tone"]}, + {"emoji":"🦶🏽","name":"foot: medium skin tone","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["foot:","medium","skin","tone"]}, + {"emoji":"🦶🏾","name":"foot: medium-dark skin tone","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["foot:","medium-dark","skin","tone"]}, + {"emoji":"🦶🏿","name":"foot: dark skin tone","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["foot:","dark","skin","tone"]}, + {"emoji":"👂","name":"ear","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["ear","body","ears","hear","hearing","listen","listening","sound"]}, + {"emoji":"👂🏻","name":"ear: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["ear:","light","skin","tone"]}, + {"emoji":"👂🏼","name":"ear: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["ear:","medium-light","skin","tone"]}, + {"emoji":"👂🏽","name":"ear: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["ear:","medium","skin","tone"]}, + {"emoji":"👂🏾","name":"ear: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["ear:","medium-dark","skin","tone"]}, + {"emoji":"👂🏿","name":"ear: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["ear:","dark","skin","tone"]}, + {"emoji":"🦻","name":"ear with hearing aid","category":"Smileys & People","version":"12.0","skinTone":false,"keywords":["ear","with","hearing","aid","accessibility","hard"]}, + {"emoji":"🦻🏻","name":"ear with hearing aid: light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["ear","with","hearing","aid:","light","skin","tone"]}, + {"emoji":"🦻🏼","name":"ear with hearing aid: medium-light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["ear","with","hearing","aid:","medium-light","skin","tone"]}, + {"emoji":"🦻🏽","name":"ear with hearing aid: medium skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["ear","with","hearing","aid:","medium","skin","tone"]}, + {"emoji":"🦻🏾","name":"ear with hearing aid: medium-dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["ear","with","hearing","aid:","medium-dark","skin","tone"]}, + {"emoji":"🦻🏿","name":"ear with hearing aid: dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["ear","with","hearing","aid:","dark","skin","tone"]}, + {"emoji":"👃","name":"nose","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["nose","body","noses","nosey","odor","smell","smells"]}, + {"emoji":"👃🏻","name":"nose: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["nose:","light","skin","tone"]}, + {"emoji":"👃🏼","name":"nose: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["nose:","medium-light","skin","tone"]}, + {"emoji":"👃🏽","name":"nose: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["nose:","medium","skin","tone"]}, + {"emoji":"👃🏾","name":"nose: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["nose:","medium-dark","skin","tone"]}, + {"emoji":"👃🏿","name":"nose: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["nose:","dark","skin","tone"]}, + {"emoji":"🧠","name":"brain","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["brain","intelligent","smart"]}, + {"emoji":"🫀","name":"anatomical heart","category":"Smileys & People","version":"13.0","skinTone":false,"keywords":["anatomical","heart","beat","cardiology","heartbeat","organ","pulse","real","red"]}, + {"emoji":"🫁","name":"lungs","category":"Smileys & People","version":"13.0","skinTone":false,"keywords":["lungs","breath","breathe","exhalation","inhalation","lung","organ","respiration"]}, + {"emoji":"🦷","name":"tooth","category":"Smileys & People","version":"11.0","skinTone":false,"keywords":["tooth","dentist","pearly","teeth","white"]}, + {"emoji":"🦴","name":"bone","category":"Smileys & People","version":"11.0","skinTone":false,"keywords":["bone","bones","dog","skeleton","wishbone"]}, + {"emoji":"👀","name":"eyes","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["eyes","body","eye","face","googly","look","looking","omg","peep","see","seeing"]}, + {"emoji":"👁️","name":"eye","category":"Smileys & People","version":"0.7","skinTone":false,"keywords":["eye"]}, + {"emoji":"👅","name":"tongue","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["tongue","body","lick","slurp"]}, + {"emoji":"👄","name":"mouth","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["mouth","beauty","body","kiss","kissing","lips","lipstick"]}, + {"emoji":"🫦","name":"biting lip","category":"Smileys & People","version":"14.0","skinTone":false,"keywords":["biting","lip","anxious","bite","fear","flirt","flirting","kiss","lipstick","nervous","sexy","uncomfortable","worried","worry"]}, + {"emoji":"👶","name":"baby","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["baby","babies","children","goo","infant","newborn","pregnant","young"]}, + {"emoji":"👶🏻","name":"baby: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["baby:","light","skin","tone"]}, + {"emoji":"👶🏼","name":"baby: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["baby:","medium-light","skin","tone"]}, + {"emoji":"👶🏽","name":"baby: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["baby:","medium","skin","tone"]}, + {"emoji":"👶🏾","name":"baby: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["baby:","medium-dark","skin","tone"]}, + {"emoji":"👶🏿","name":"baby: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["baby:","dark","skin","tone"]}, + {"emoji":"🧒","name":"child","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["child","bright-eyed","grandchild","kid","young","younger"]}, + {"emoji":"🧒🏻","name":"child: light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["child:","light","skin","tone"]}, + {"emoji":"🧒🏼","name":"child: medium-light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["child:","medium-light","skin","tone"]}, + {"emoji":"🧒🏽","name":"child: medium skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["child:","medium","skin","tone"]}, + {"emoji":"🧒🏾","name":"child: medium-dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["child:","medium-dark","skin","tone"]}, + {"emoji":"🧒🏿","name":"child: dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["child:","dark","skin","tone"]}, + {"emoji":"👦","name":"boy","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["boy","bright-eyed","child","grandson","kid","son","young","younger"]}, + {"emoji":"👦🏻","name":"boy: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["boy:","light","skin","tone"]}, + {"emoji":"👦🏼","name":"boy: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["boy:","medium-light","skin","tone"]}, + {"emoji":"👦🏽","name":"boy: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["boy:","medium","skin","tone"]}, + {"emoji":"👦🏾","name":"boy: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["boy:","medium-dark","skin","tone"]}, + {"emoji":"👦🏿","name":"boy: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["boy:","dark","skin","tone"]}, + {"emoji":"👧","name":"girl","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["girl","bright-eyed","child","daughter","granddaughter","kid","Virgo","young","younger","zodiac"]}, + {"emoji":"👧🏻","name":"girl: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["girl:","light","skin","tone"]}, + {"emoji":"👧🏼","name":"girl: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["girl:","medium-light","skin","tone"]}, + {"emoji":"👧🏽","name":"girl: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["girl:","medium","skin","tone"]}, + {"emoji":"👧🏾","name":"girl: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["girl:","medium-dark","skin","tone"]}, + {"emoji":"👧🏿","name":"girl: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["girl:","dark","skin","tone"]}, + {"emoji":"🧑","name":"person","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["person","adult"]}, + {"emoji":"🧑🏻","name":"person: light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["person:","light","skin","tone"]}, + {"emoji":"🧑🏼","name":"person: medium-light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["person:","medium-light","skin","tone"]}, + {"emoji":"🧑🏽","name":"person: medium skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["person:","medium","skin","tone"]}, + {"emoji":"🧑🏾","name":"person: medium-dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["person:","medium-dark","skin","tone"]}, + {"emoji":"🧑🏿","name":"person: dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["person:","dark","skin","tone"]}, + {"emoji":"👱","name":"person: blond hair","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["person:","blond","hair","blond-haired","human","person"]}, + {"emoji":"👱🏻","name":"person: light skin tone, blond hair","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person:","light","skin","tone,","blond","hair"]}, + {"emoji":"👱🏼","name":"person: medium-light skin tone, blond hair","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person:","medium-light","skin","tone,","blond","hair"]}, + {"emoji":"👱🏽","name":"person: medium skin tone, blond hair","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person:","medium","skin","tone,","blond","hair"]}, + {"emoji":"👱🏾","name":"person: medium-dark skin tone, blond hair","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person:","medium-dark","skin","tone,","blond","hair"]}, + {"emoji":"👱🏿","name":"person: dark skin tone, blond hair","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person:","dark","skin","tone,","blond","hair"]}, + {"emoji":"👨","name":"man","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["man","adult","bro"]}, + {"emoji":"👨🏻","name":"man: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["man:","light","skin","tone"]}, + {"emoji":"👨🏼","name":"man: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["man:","medium-light","skin","tone"]}, + {"emoji":"👨🏽","name":"man: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["man:","medium","skin","tone"]}, + {"emoji":"👨🏾","name":"man: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["man:","medium-dark","skin","tone"]}, + {"emoji":"👨🏿","name":"man: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["man:","dark","skin","tone"]}, + {"emoji":"🧔","name":"person: beard","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["person:","beard","bearded","person","whiskers"]}, + {"emoji":"🧔🏻","name":"person: light skin tone, beard","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["person:","light","skin","tone,","beard"]}, + {"emoji":"🧔🏼","name":"person: medium-light skin tone, beard","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["person:","medium-light","skin","tone,","beard"]}, + {"emoji":"🧔🏽","name":"person: medium skin tone, beard","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["person:","medium","skin","tone,","beard"]}, + {"emoji":"🧔🏾","name":"person: medium-dark skin tone, beard","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["person:","medium-dark","skin","tone,","beard"]}, + {"emoji":"🧔🏿","name":"person: dark skin tone, beard","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["person:","dark","skin","tone,","beard"]}, + {"emoji":"🧔‍♂️","name":"man: beard","category":"Smileys & People","version":"13.1","skinTone":false,"keywords":["man:","beard"]}, + {"emoji":"🧔🏻‍♂️","name":"man: light skin tone, beard","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["man:","light","skin","tone,","beard"]}, + {"emoji":"🧔🏼‍♂️","name":"man: medium-light skin tone, beard","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["man:","medium-light","skin","tone,","beard"]}, + {"emoji":"🧔🏽‍♂️","name":"man: medium skin tone, beard","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["man:","medium","skin","tone,","beard"]}, + {"emoji":"🧔🏾‍♂️","name":"man: medium-dark skin tone, beard","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["man:","medium-dark","skin","tone,","beard"]}, + {"emoji":"🧔🏿‍♂️","name":"man: dark skin tone, beard","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["man:","dark","skin","tone,","beard"]}, + {"emoji":"🧔‍♀️","name":"woman: beard","category":"Smileys & People","version":"13.1","skinTone":false,"keywords":["woman:","beard"]}, + {"emoji":"🧔🏻‍♀️","name":"woman: light skin tone, beard","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["woman:","light","skin","tone,","beard"]}, + {"emoji":"🧔🏼‍♀️","name":"woman: medium-light skin tone, beard","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["woman:","medium-light","skin","tone,","beard"]}, + {"emoji":"🧔🏽‍♀️","name":"woman: medium skin tone, beard","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["woman:","medium","skin","tone,","beard"]}, + {"emoji":"🧔🏾‍♀️","name":"woman: medium-dark skin tone, beard","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["woman:","medium-dark","skin","tone,","beard"]}, + {"emoji":"🧔🏿‍♀️","name":"woman: dark skin tone, beard","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["woman:","dark","skin","tone,","beard"]}, + {"emoji":"👨‍🦰","name":"man: red hair","category":"Smileys & People","version":"11.0","skinTone":false,"keywords":["man:","red","hair"]}, + {"emoji":"👨🏻‍🦰","name":"man: light skin tone, red hair","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["man:","light","skin","tone,","red","hair"]}, + {"emoji":"👨🏼‍🦰","name":"man: medium-light skin tone, red hair","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["man:","medium-light","skin","tone,","red","hair"]}, + {"emoji":"👨🏽‍🦰","name":"man: medium skin tone, red hair","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["man:","medium","skin","tone,","red","hair"]}, + {"emoji":"👨🏾‍🦰","name":"man: medium-dark skin tone, red hair","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["man:","medium-dark","skin","tone,","red","hair"]}, + {"emoji":"👨🏿‍🦰","name":"man: dark skin tone, red hair","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["man:","dark","skin","tone,","red","hair"]}, + {"emoji":"👨‍🦱","name":"man: curly hair","category":"Smileys & People","version":"11.0","skinTone":false,"keywords":["man:","curly","hair"]}, + {"emoji":"👨🏻‍🦱","name":"man: light skin tone, curly hair","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["man:","light","skin","tone,","curly","hair"]}, + {"emoji":"👨🏼‍🦱","name":"man: medium-light skin tone, curly hair","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["man:","medium-light","skin","tone,","curly","hair"]}, + {"emoji":"👨🏽‍🦱","name":"man: medium skin tone, curly hair","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["man:","medium","skin","tone,","curly","hair"]}, + {"emoji":"👨🏾‍🦱","name":"man: medium-dark skin tone, curly hair","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["man:","medium-dark","skin","tone,","curly","hair"]}, + {"emoji":"👨🏿‍🦱","name":"man: dark skin tone, curly hair","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["man:","dark","skin","tone,","curly","hair"]}, + {"emoji":"👨‍🦳","name":"man: white hair","category":"Smileys & People","version":"11.0","skinTone":false,"keywords":["man:","white","hair"]}, + {"emoji":"👨🏻‍🦳","name":"man: light skin tone, white hair","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["man:","light","skin","tone,","white","hair"]}, + {"emoji":"👨🏼‍🦳","name":"man: medium-light skin tone, white hair","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["man:","medium-light","skin","tone,","white","hair"]}, + {"emoji":"👨🏽‍🦳","name":"man: medium skin tone, white hair","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["man:","medium","skin","tone,","white","hair"]}, + {"emoji":"👨🏾‍🦳","name":"man: medium-dark skin tone, white hair","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["man:","medium-dark","skin","tone,","white","hair"]}, + {"emoji":"👨🏿‍🦳","name":"man: dark skin tone, white hair","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["man:","dark","skin","tone,","white","hair"]}, + {"emoji":"👨‍🦲","name":"man: bald","category":"Smileys & People","version":"11.0","skinTone":false,"keywords":["man:","bald"]}, + {"emoji":"👨🏻‍🦲","name":"man: light skin tone, bald","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["man:","light","skin","tone,","bald"]}, + {"emoji":"👨🏼‍🦲","name":"man: medium-light skin tone, bald","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["man:","medium-light","skin","tone,","bald"]}, + {"emoji":"👨🏽‍🦲","name":"man: medium skin tone, bald","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["man:","medium","skin","tone,","bald"]}, + {"emoji":"👨🏾‍🦲","name":"man: medium-dark skin tone, bald","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["man:","medium-dark","skin","tone,","bald"]}, + {"emoji":"👨🏿‍🦲","name":"man: dark skin tone, bald","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["man:","dark","skin","tone,","bald"]}, + {"emoji":"👩","name":"woman","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["woman","adult","lady"]}, + {"emoji":"👩🏻","name":"woman: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["woman:","light","skin","tone"]}, + {"emoji":"👩🏼","name":"woman: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["woman:","medium-light","skin","tone"]}, + {"emoji":"👩🏽","name":"woman: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["woman:","medium","skin","tone"]}, + {"emoji":"👩🏾","name":"woman: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["woman:","medium-dark","skin","tone"]}, + {"emoji":"👩🏿","name":"woman: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["woman:","dark","skin","tone"]}, + {"emoji":"👩‍🦰","name":"woman: red hair","category":"Smileys & People","version":"11.0","skinTone":false,"keywords":["woman:","red","hair"]}, + {"emoji":"👩🏻‍🦰","name":"woman: light skin tone, red hair","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["woman:","light","skin","tone,","red","hair"]}, + {"emoji":"👩🏼‍🦰","name":"woman: medium-light skin tone, red hair","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["woman:","medium-light","skin","tone,","red","hair"]}, + {"emoji":"👩🏽‍🦰","name":"woman: medium skin tone, red hair","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["woman:","medium","skin","tone,","red","hair"]}, + {"emoji":"👩🏾‍🦰","name":"woman: medium-dark skin tone, red hair","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["woman:","medium-dark","skin","tone,","red","hair"]}, + {"emoji":"👩🏿‍🦰","name":"woman: dark skin tone, red hair","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["woman:","dark","skin","tone,","red","hair"]}, + {"emoji":"🧑‍🦰","name":"person: red hair","category":"Smileys & People","version":"12.1","skinTone":false,"keywords":["person:","red","hair"]}, + {"emoji":"🧑🏻‍🦰","name":"person: light skin tone, red hair","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["person:","light","skin","tone,","red","hair"]}, + {"emoji":"🧑🏼‍🦰","name":"person: medium-light skin tone, red hair","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["person:","medium-light","skin","tone,","red","hair"]}, + {"emoji":"🧑🏽‍🦰","name":"person: medium skin tone, red hair","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["person:","medium","skin","tone,","red","hair"]}, + {"emoji":"🧑🏾‍🦰","name":"person: medium-dark skin tone, red hair","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["person:","medium-dark","skin","tone,","red","hair"]}, + {"emoji":"🧑🏿‍🦰","name":"person: dark skin tone, red hair","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["person:","dark","skin","tone,","red","hair"]}, + {"emoji":"👩‍🦱","name":"woman: curly hair","category":"Smileys & People","version":"11.0","skinTone":false,"keywords":["woman:","curly","hair"]}, + {"emoji":"👩🏻‍🦱","name":"woman: light skin tone, curly hair","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["woman:","light","skin","tone,","curly","hair"]}, + {"emoji":"👩🏼‍🦱","name":"woman: medium-light skin tone, curly hair","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["woman:","medium-light","skin","tone,","curly","hair"]}, + {"emoji":"👩🏽‍🦱","name":"woman: medium skin tone, curly hair","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["woman:","medium","skin","tone,","curly","hair"]}, + {"emoji":"👩🏾‍🦱","name":"woman: medium-dark skin tone, curly hair","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["woman:","medium-dark","skin","tone,","curly","hair"]}, + {"emoji":"👩🏿‍🦱","name":"woman: dark skin tone, curly hair","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["woman:","dark","skin","tone,","curly","hair"]}, + {"emoji":"🧑‍🦱","name":"person: curly hair","category":"Smileys & People","version":"12.1","skinTone":false,"keywords":["person:","curly","hair"]}, + {"emoji":"🧑🏻‍🦱","name":"person: light skin tone, curly hair","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["person:","light","skin","tone,","curly","hair"]}, + {"emoji":"🧑🏼‍🦱","name":"person: medium-light skin tone, curly hair","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["person:","medium-light","skin","tone,","curly","hair"]}, + {"emoji":"🧑🏽‍🦱","name":"person: medium skin tone, curly hair","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["person:","medium","skin","tone,","curly","hair"]}, + {"emoji":"🧑🏾‍🦱","name":"person: medium-dark skin tone, curly hair","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["person:","medium-dark","skin","tone,","curly","hair"]}, + {"emoji":"🧑🏿‍🦱","name":"person: dark skin tone, curly hair","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["person:","dark","skin","tone,","curly","hair"]}, + {"emoji":"👩‍🦳","name":"woman: white hair","category":"Smileys & People","version":"11.0","skinTone":false,"keywords":["woman:","white","hair"]}, + {"emoji":"👩🏻‍🦳","name":"woman: light skin tone, white hair","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["woman:","light","skin","tone,","white","hair"]}, + {"emoji":"👩🏼‍🦳","name":"woman: medium-light skin tone, white hair","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["woman:","medium-light","skin","tone,","white","hair"]}, + {"emoji":"👩🏽‍🦳","name":"woman: medium skin tone, white hair","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["woman:","medium","skin","tone,","white","hair"]}, + {"emoji":"👩🏾‍🦳","name":"woman: medium-dark skin tone, white hair","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["woman:","medium-dark","skin","tone,","white","hair"]}, + {"emoji":"👩🏿‍🦳","name":"woman: dark skin tone, white hair","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["woman:","dark","skin","tone,","white","hair"]}, + {"emoji":"🧑‍🦳","name":"person: white hair","category":"Smileys & People","version":"12.1","skinTone":false,"keywords":["person:","white","hair"]}, + {"emoji":"🧑🏻‍🦳","name":"person: light skin tone, white hair","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["person:","light","skin","tone,","white","hair"]}, + {"emoji":"🧑🏼‍🦳","name":"person: medium-light skin tone, white hair","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["person:","medium-light","skin","tone,","white","hair"]}, + {"emoji":"🧑🏽‍🦳","name":"person: medium skin tone, white hair","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["person:","medium","skin","tone,","white","hair"]}, + {"emoji":"🧑🏾‍🦳","name":"person: medium-dark skin tone, white hair","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["person:","medium-dark","skin","tone,","white","hair"]}, + {"emoji":"🧑🏿‍🦳","name":"person: dark skin tone, white hair","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["person:","dark","skin","tone,","white","hair"]}, + {"emoji":"👩‍🦲","name":"woman: bald","category":"Smileys & People","version":"11.0","skinTone":false,"keywords":["woman:","bald"]}, + {"emoji":"👩🏻‍🦲","name":"woman: light skin tone, bald","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["woman:","light","skin","tone,","bald"]}, + {"emoji":"👩🏼‍🦲","name":"woman: medium-light skin tone, bald","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["woman:","medium-light","skin","tone,","bald"]}, + {"emoji":"👩🏽‍🦲","name":"woman: medium skin tone, bald","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["woman:","medium","skin","tone,","bald"]}, + {"emoji":"👩🏾‍🦲","name":"woman: medium-dark skin tone, bald","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["woman:","medium-dark","skin","tone,","bald"]}, + {"emoji":"👩🏿‍🦲","name":"woman: dark skin tone, bald","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["woman:","dark","skin","tone,","bald"]}, + {"emoji":"🧑‍🦲","name":"person: bald","category":"Smileys & People","version":"12.1","skinTone":false,"keywords":["person:","bald"]}, + {"emoji":"🧑🏻‍🦲","name":"person: light skin tone, bald","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["person:","light","skin","tone,","bald"]}, + {"emoji":"🧑🏼‍🦲","name":"person: medium-light skin tone, bald","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["person:","medium-light","skin","tone,","bald"]}, + {"emoji":"🧑🏽‍🦲","name":"person: medium skin tone, bald","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["person:","medium","skin","tone,","bald"]}, + {"emoji":"🧑🏾‍🦲","name":"person: medium-dark skin tone, bald","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["person:","medium-dark","skin","tone,","bald"]}, + {"emoji":"🧑🏿‍🦲","name":"person: dark skin tone, bald","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["person:","dark","skin","tone,","bald"]}, + {"emoji":"👱‍♀️","name":"woman: blond hair","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman:","blond","hair"]}, + {"emoji":"👱🏻‍♀️","name":"woman: light skin tone, blond hair","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman:","light","skin","tone,","blond","hair"]}, + {"emoji":"👱🏼‍♀️","name":"woman: medium-light skin tone, blond hair","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman:","medium-light","skin","tone,","blond","hair"]}, + {"emoji":"👱🏽‍♀️","name":"woman: medium skin tone, blond hair","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman:","medium","skin","tone,","blond","hair"]}, + {"emoji":"👱🏾‍♀️","name":"woman: medium-dark skin tone, blond hair","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman:","medium-dark","skin","tone,","blond","hair"]}, + {"emoji":"👱🏿‍♀️","name":"woman: dark skin tone, blond hair","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman:","dark","skin","tone,","blond","hair"]}, + {"emoji":"👱‍♂️","name":"man: blond hair","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man:","blond","hair"]}, + {"emoji":"👱🏻‍♂️","name":"man: light skin tone, blond hair","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man:","light","skin","tone,","blond","hair"]}, + {"emoji":"👱🏼‍♂️","name":"man: medium-light skin tone, blond hair","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man:","medium-light","skin","tone,","blond","hair"]}, + {"emoji":"👱🏽‍♂️","name":"man: medium skin tone, blond hair","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man:","medium","skin","tone,","blond","hair"]}, + {"emoji":"👱🏾‍♂️","name":"man: medium-dark skin tone, blond hair","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man:","medium-dark","skin","tone,","blond","hair"]}, + {"emoji":"👱🏿‍♂️","name":"man: dark skin tone, blond hair","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man:","dark","skin","tone,","blond","hair"]}, + {"emoji":"🧓","name":"older person","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["older","person","adult","elderly","grandparent","old","wise"]}, + {"emoji":"🧓🏻","name":"older person: light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["older","person:","light","skin","tone"]}, + {"emoji":"🧓🏼","name":"older person: medium-light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["older","person:","medium-light","skin","tone"]}, + {"emoji":"🧓🏽","name":"older person: medium skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["older","person:","medium","skin","tone"]}, + {"emoji":"🧓🏾","name":"older person: medium-dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["older","person:","medium-dark","skin","tone"]}, + {"emoji":"🧓🏿","name":"older person: dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["older","person:","dark","skin","tone"]}, + {"emoji":"👴","name":"old man","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["old","man","adult","bald","elderly","gramps","grandfather","grandpa","wise"]}, + {"emoji":"👴🏻","name":"old man: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["old","man:","light","skin","tone"]}, + {"emoji":"👴🏼","name":"old man: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["old","man:","medium-light","skin","tone"]}, + {"emoji":"👴🏽","name":"old man: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["old","man:","medium","skin","tone"]}, + {"emoji":"👴🏾","name":"old man: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["old","man:","medium-dark","skin","tone"]}, + {"emoji":"👴🏿","name":"old man: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["old","man:","dark","skin","tone"]}, + {"emoji":"👵","name":"old woman","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["old","woman","adult","elderly","grandma","grandmother","granny","lady","wise"]}, + {"emoji":"👵🏻","name":"old woman: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["old","woman:","light","skin","tone"]}, + {"emoji":"👵🏼","name":"old woman: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["old","woman:","medium-light","skin","tone"]}, + {"emoji":"👵🏽","name":"old woman: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["old","woman:","medium","skin","tone"]}, + {"emoji":"👵🏾","name":"old woman: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["old","woman:","medium-dark","skin","tone"]}, + {"emoji":"👵🏿","name":"old woman: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["old","woman:","dark","skin","tone"]}, + {"emoji":"🙍","name":"person frowning","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["person","frowning","annoyed","disappointed","disgruntled","disturbed","frown","frustrated","gesture","irritated","upset"]}, + {"emoji":"🙍🏻","name":"person frowning: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","frowning:","light","skin","tone"]}, + {"emoji":"🙍🏼","name":"person frowning: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","frowning:","medium-light","skin","tone"]}, + {"emoji":"🙍🏽","name":"person frowning: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","frowning:","medium","skin","tone"]}, + {"emoji":"🙍🏾","name":"person frowning: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","frowning:","medium-dark","skin","tone"]}, + {"emoji":"🙍🏿","name":"person frowning: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","frowning:","dark","skin","tone"]}, + {"emoji":"🙍‍♂️","name":"man frowning","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man","frowning"]}, + {"emoji":"🙍🏻‍♂️","name":"man frowning: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","frowning:","light","skin","tone"]}, + {"emoji":"🙍🏼‍♂️","name":"man frowning: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","frowning:","medium-light","skin","tone"]}, + {"emoji":"🙍🏽‍♂️","name":"man frowning: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","frowning:","medium","skin","tone"]}, + {"emoji":"🙍🏾‍♂️","name":"man frowning: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","frowning:","medium-dark","skin","tone"]}, + {"emoji":"🙍🏿‍♂️","name":"man frowning: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","frowning:","dark","skin","tone"]}, + {"emoji":"🙍‍♀️","name":"woman frowning","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman","frowning"]}, + {"emoji":"🙍🏻‍♀️","name":"woman frowning: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","frowning:","light","skin","tone"]}, + {"emoji":"🙍🏼‍♀️","name":"woman frowning: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","frowning:","medium-light","skin","tone"]}, + {"emoji":"🙍🏽‍♀️","name":"woman frowning: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","frowning:","medium","skin","tone"]}, + {"emoji":"🙍🏾‍♀️","name":"woman frowning: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","frowning:","medium-dark","skin","tone"]}, + {"emoji":"🙍🏿‍♀️","name":"woman frowning: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","frowning:","dark","skin","tone"]}, + {"emoji":"🙎","name":"person pouting","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["person","pouting","disappointed","downtrodden","frown","grimace","scowl","sulk","upset","whine"]}, + {"emoji":"🙎🏻","name":"person pouting: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","pouting:","light","skin","tone"]}, + {"emoji":"🙎🏼","name":"person pouting: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","pouting:","medium-light","skin","tone"]}, + {"emoji":"🙎🏽","name":"person pouting: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","pouting:","medium","skin","tone"]}, + {"emoji":"🙎🏾","name":"person pouting: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","pouting:","medium-dark","skin","tone"]}, + {"emoji":"🙎🏿","name":"person pouting: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","pouting:","dark","skin","tone"]}, + {"emoji":"🙎‍♂️","name":"man pouting","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man","pouting"]}, + {"emoji":"🙎🏻‍♂️","name":"man pouting: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","pouting:","light","skin","tone"]}, + {"emoji":"🙎🏼‍♂️","name":"man pouting: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","pouting:","medium-light","skin","tone"]}, + {"emoji":"🙎🏽‍♂️","name":"man pouting: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","pouting:","medium","skin","tone"]}, + {"emoji":"🙎🏾‍♂️","name":"man pouting: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","pouting:","medium-dark","skin","tone"]}, + {"emoji":"🙎🏿‍♂️","name":"man pouting: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","pouting:","dark","skin","tone"]}, + {"emoji":"🙎‍♀️","name":"woman pouting","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman","pouting"]}, + {"emoji":"🙎🏻‍♀️","name":"woman pouting: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","pouting:","light","skin","tone"]}, + {"emoji":"🙎🏼‍♀️","name":"woman pouting: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","pouting:","medium-light","skin","tone"]}, + {"emoji":"🙎🏽‍♀️","name":"woman pouting: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","pouting:","medium","skin","tone"]}, + {"emoji":"🙎🏾‍♀️","name":"woman pouting: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","pouting:","medium-dark","skin","tone"]}, + {"emoji":"🙎🏿‍♀️","name":"woman pouting: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","pouting:","dark","skin","tone"]}, + {"emoji":"🙅","name":"person gesturing NO","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["person","gesturing","NO","forbidden","gesture","hand","not","prohibit"]}, + {"emoji":"🙅🏻","name":"person gesturing NO: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","gesturing","NO:","light","skin","tone"]}, + {"emoji":"🙅🏼","name":"person gesturing NO: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","gesturing","NO:","medium-light","skin","tone"]}, + {"emoji":"🙅🏽","name":"person gesturing NO: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","gesturing","NO:","medium","skin","tone"]}, + {"emoji":"🙅🏾","name":"person gesturing NO: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","gesturing","NO:","medium-dark","skin","tone"]}, + {"emoji":"🙅🏿","name":"person gesturing NO: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","gesturing","NO:","dark","skin","tone"]}, + {"emoji":"🙅‍♂️","name":"man gesturing NO","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man","gesturing","NO"]}, + {"emoji":"🙅🏻‍♂️","name":"man gesturing NO: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","gesturing","NO:","light","skin","tone"]}, + {"emoji":"🙅🏼‍♂️","name":"man gesturing NO: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","gesturing","NO:","medium-light","skin","tone"]}, + {"emoji":"🙅🏽‍♂️","name":"man gesturing NO: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","gesturing","NO:","medium","skin","tone"]}, + {"emoji":"🙅🏾‍♂️","name":"man gesturing NO: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","gesturing","NO:","medium-dark","skin","tone"]}, + {"emoji":"🙅🏿‍♂️","name":"man gesturing NO: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","gesturing","NO:","dark","skin","tone"]}, + {"emoji":"🙅‍♀️","name":"woman gesturing NO","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman","gesturing","NO"]}, + {"emoji":"🙅🏻‍♀️","name":"woman gesturing NO: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","gesturing","NO:","light","skin","tone"]}, + {"emoji":"🙅🏼‍♀️","name":"woman gesturing NO: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","gesturing","NO:","medium-light","skin","tone"]}, + {"emoji":"🙅🏽‍♀️","name":"woman gesturing NO: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","gesturing","NO:","medium","skin","tone"]}, + {"emoji":"🙅🏾‍♀️","name":"woman gesturing NO: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","gesturing","NO:","medium-dark","skin","tone"]}, + {"emoji":"🙅🏿‍♀️","name":"woman gesturing NO: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","gesturing","NO:","dark","skin","tone"]}, + {"emoji":"🙆","name":"person gesturing OK","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["person","gesturing","OK","exercise","gesture","hand","omg"]}, + {"emoji":"🙆🏻","name":"person gesturing OK: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","gesturing","OK:","light","skin","tone"]}, + {"emoji":"🙆🏼","name":"person gesturing OK: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","gesturing","OK:","medium-light","skin","tone"]}, + {"emoji":"🙆🏽","name":"person gesturing OK: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","gesturing","OK:","medium","skin","tone"]}, + {"emoji":"🙆🏾","name":"person gesturing OK: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","gesturing","OK:","medium-dark","skin","tone"]}, + {"emoji":"🙆🏿","name":"person gesturing OK: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","gesturing","OK:","dark","skin","tone"]}, + {"emoji":"🙆‍♂️","name":"man gesturing OK","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man","gesturing","OK"]}, + {"emoji":"🙆🏻‍♂️","name":"man gesturing OK: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","gesturing","OK:","light","skin","tone"]}, + {"emoji":"🙆🏼‍♂️","name":"man gesturing OK: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","gesturing","OK:","medium-light","skin","tone"]}, + {"emoji":"🙆🏽‍♂️","name":"man gesturing OK: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","gesturing","OK:","medium","skin","tone"]}, + {"emoji":"🙆🏾‍♂️","name":"man gesturing OK: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","gesturing","OK:","medium-dark","skin","tone"]}, + {"emoji":"🙆🏿‍♂️","name":"man gesturing OK: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","gesturing","OK:","dark","skin","tone"]}, + {"emoji":"🙆‍♀️","name":"woman gesturing OK","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman","gesturing","OK"]}, + {"emoji":"🙆🏻‍♀️","name":"woman gesturing OK: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","gesturing","OK:","light","skin","tone"]}, + {"emoji":"🙆🏼‍♀️","name":"woman gesturing OK: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","gesturing","OK:","medium-light","skin","tone"]}, + {"emoji":"🙆🏽‍♀️","name":"woman gesturing OK: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","gesturing","OK:","medium","skin","tone"]}, + {"emoji":"🙆🏾‍♀️","name":"woman gesturing OK: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","gesturing","OK:","medium-dark","skin","tone"]}, + {"emoji":"🙆🏿‍♀️","name":"woman gesturing OK: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","gesturing","OK:","dark","skin","tone"]}, + {"emoji":"💁","name":"person tipping hand","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["person","tipping","hand","fetch","flick","flip","gossip","sarcasm","sarcastic","sassy","seriously","whatever"]}, + {"emoji":"💁🏻","name":"person tipping hand: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","tipping","hand:","light","skin","tone"]}, + {"emoji":"💁🏼","name":"person tipping hand: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","tipping","hand:","medium-light","skin","tone"]}, + {"emoji":"💁🏽","name":"person tipping hand: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","tipping","hand:","medium","skin","tone"]}, + {"emoji":"💁🏾","name":"person tipping hand: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","tipping","hand:","medium-dark","skin","tone"]}, + {"emoji":"💁🏿","name":"person tipping hand: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","tipping","hand:","dark","skin","tone"]}, + {"emoji":"💁‍♂️","name":"man tipping hand","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man","tipping","hand"]}, + {"emoji":"💁🏻‍♂️","name":"man tipping hand: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","tipping","hand:","light","skin","tone"]}, + {"emoji":"💁🏼‍♂️","name":"man tipping hand: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","tipping","hand:","medium-light","skin","tone"]}, + {"emoji":"💁🏽‍♂️","name":"man tipping hand: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","tipping","hand:","medium","skin","tone"]}, + {"emoji":"💁🏾‍♂️","name":"man tipping hand: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","tipping","hand:","medium-dark","skin","tone"]}, + {"emoji":"💁🏿‍♂️","name":"man tipping hand: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","tipping","hand:","dark","skin","tone"]}, + {"emoji":"💁‍♀️","name":"woman tipping hand","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman","tipping","hand"]}, + {"emoji":"💁🏻‍♀️","name":"woman tipping hand: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","tipping","hand:","light","skin","tone"]}, + {"emoji":"💁🏼‍♀️","name":"woman tipping hand: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","tipping","hand:","medium-light","skin","tone"]}, + {"emoji":"💁🏽‍♀️","name":"woman tipping hand: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","tipping","hand:","medium","skin","tone"]}, + {"emoji":"💁🏾‍♀️","name":"woman tipping hand: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","tipping","hand:","medium-dark","skin","tone"]}, + {"emoji":"💁🏿‍♀️","name":"woman tipping hand: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","tipping","hand:","dark","skin","tone"]}, + {"emoji":"🙋","name":"person raising hand","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["person","raising","hand","gesture","here","know","me","pick","question","raise"]}, + {"emoji":"🙋🏻","name":"person raising hand: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","raising","hand:","light","skin","tone"]}, + {"emoji":"🙋🏼","name":"person raising hand: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","raising","hand:","medium-light","skin","tone"]}, + {"emoji":"🙋🏽","name":"person raising hand: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","raising","hand:","medium","skin","tone"]}, + {"emoji":"🙋🏾","name":"person raising hand: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","raising","hand:","medium-dark","skin","tone"]}, + {"emoji":"🙋🏿","name":"person raising hand: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","raising","hand:","dark","skin","tone"]}, + {"emoji":"🙋‍♂️","name":"man raising hand","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man","raising","hand"]}, + {"emoji":"🙋🏻‍♂️","name":"man raising hand: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","raising","hand:","light","skin","tone"]}, + {"emoji":"🙋🏼‍♂️","name":"man raising hand: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","raising","hand:","medium-light","skin","tone"]}, + {"emoji":"🙋🏽‍♂️","name":"man raising hand: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","raising","hand:","medium","skin","tone"]}, + {"emoji":"🙋🏾‍♂️","name":"man raising hand: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","raising","hand:","medium-dark","skin","tone"]}, + {"emoji":"🙋🏿‍♂️","name":"man raising hand: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","raising","hand:","dark","skin","tone"]}, + {"emoji":"🙋‍♀️","name":"woman raising hand","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman","raising","hand"]}, + {"emoji":"🙋🏻‍♀️","name":"woman raising hand: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","raising","hand:","light","skin","tone"]}, + {"emoji":"🙋🏼‍♀️","name":"woman raising hand: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","raising","hand:","medium-light","skin","tone"]}, + {"emoji":"🙋🏽‍♀️","name":"woman raising hand: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","raising","hand:","medium","skin","tone"]}, + {"emoji":"🙋🏾‍♀️","name":"woman raising hand: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","raising","hand:","medium-dark","skin","tone"]}, + {"emoji":"🙋🏿‍♀️","name":"woman raising hand: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","raising","hand:","dark","skin","tone"]}, + {"emoji":"🧏","name":"deaf person","category":"Smileys & People","version":"12.0","skinTone":false,"keywords":["deaf","person","accessibility","ear","gesture","hear"]}, + {"emoji":"🧏🏻","name":"deaf person: light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["deaf","person:","light","skin","tone"]}, + {"emoji":"🧏🏼","name":"deaf person: medium-light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["deaf","person:","medium-light","skin","tone"]}, + {"emoji":"🧏🏽","name":"deaf person: medium skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["deaf","person:","medium","skin","tone"]}, + {"emoji":"🧏🏾","name":"deaf person: medium-dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["deaf","person:","medium-dark","skin","tone"]}, + {"emoji":"🧏🏿","name":"deaf person: dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["deaf","person:","dark","skin","tone"]}, + {"emoji":"🧏‍♂️","name":"deaf man","category":"Smileys & People","version":"12.0","skinTone":false,"keywords":["deaf","man"]}, + {"emoji":"🧏🏻‍♂️","name":"deaf man: light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["deaf","man:","light","skin","tone"]}, + {"emoji":"🧏🏼‍♂️","name":"deaf man: medium-light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["deaf","man:","medium-light","skin","tone"]}, + {"emoji":"🧏🏽‍♂️","name":"deaf man: medium skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["deaf","man:","medium","skin","tone"]}, + {"emoji":"🧏🏾‍♂️","name":"deaf man: medium-dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["deaf","man:","medium-dark","skin","tone"]}, + {"emoji":"🧏🏿‍♂️","name":"deaf man: dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["deaf","man:","dark","skin","tone"]}, + {"emoji":"🧏‍♀️","name":"deaf woman","category":"Smileys & People","version":"12.0","skinTone":false,"keywords":["deaf","woman"]}, + {"emoji":"🧏🏻‍♀️","name":"deaf woman: light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["deaf","woman:","light","skin","tone"]}, + {"emoji":"🧏🏼‍♀️","name":"deaf woman: medium-light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["deaf","woman:","medium-light","skin","tone"]}, + {"emoji":"🧏🏽‍♀️","name":"deaf woman: medium skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["deaf","woman:","medium","skin","tone"]}, + {"emoji":"🧏🏾‍♀️","name":"deaf woman: medium-dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["deaf","woman:","medium-dark","skin","tone"]}, + {"emoji":"🧏🏿‍♀️","name":"deaf woman: dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["deaf","woman:","dark","skin","tone"]}, + {"emoji":"🙇","name":"person bowing","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["person","bowing","apology","ask","beg","bow","favor","forgive","gesture","meditate","meditation","pity","regret","sorry"]}, + {"emoji":"🙇🏻","name":"person bowing: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","bowing:","light","skin","tone"]}, + {"emoji":"🙇🏼","name":"person bowing: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","bowing:","medium-light","skin","tone"]}, + {"emoji":"🙇🏽","name":"person bowing: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","bowing:","medium","skin","tone"]}, + {"emoji":"🙇🏾","name":"person bowing: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","bowing:","medium-dark","skin","tone"]}, + {"emoji":"🙇🏿","name":"person bowing: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","bowing:","dark","skin","tone"]}, + {"emoji":"🙇‍♂️","name":"man bowing","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man","bowing"]}, + {"emoji":"🙇🏻‍♂️","name":"man bowing: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","bowing:","light","skin","tone"]}, + {"emoji":"🙇🏼‍♂️","name":"man bowing: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","bowing:","medium-light","skin","tone"]}, + {"emoji":"🙇🏽‍♂️","name":"man bowing: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","bowing:","medium","skin","tone"]}, + {"emoji":"🙇🏾‍♂️","name":"man bowing: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","bowing:","medium-dark","skin","tone"]}, + {"emoji":"🙇🏿‍♂️","name":"man bowing: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","bowing:","dark","skin","tone"]}, + {"emoji":"🙇‍♀️","name":"woman bowing","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman","bowing"]}, + {"emoji":"🙇🏻‍♀️","name":"woman bowing: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","bowing:","light","skin","tone"]}, + {"emoji":"🙇🏼‍♀️","name":"woman bowing: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","bowing:","medium-light","skin","tone"]}, + {"emoji":"🙇🏽‍♀️","name":"woman bowing: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","bowing:","medium","skin","tone"]}, + {"emoji":"🙇🏾‍♀️","name":"woman bowing: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","bowing:","medium-dark","skin","tone"]}, + {"emoji":"🙇🏿‍♀️","name":"woman bowing: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","bowing:","dark","skin","tone"]}, + {"emoji":"🤦","name":"person facepalming","category":"Smileys & People","version":"3.0","skinTone":false,"keywords":["person","facepalming","again","bewilder","disbelief","exasperation","facepalm","no","not","oh","omg","shock","smh"]}, + {"emoji":"🤦🏻","name":"person facepalming: light skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["person","facepalming:","light","skin","tone"]}, + {"emoji":"🤦🏼","name":"person facepalming: medium-light skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["person","facepalming:","medium-light","skin","tone"]}, + {"emoji":"🤦🏽","name":"person facepalming: medium skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["person","facepalming:","medium","skin","tone"]}, + {"emoji":"🤦🏾","name":"person facepalming: medium-dark skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["person","facepalming:","medium-dark","skin","tone"]}, + {"emoji":"🤦🏿","name":"person facepalming: dark skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["person","facepalming:","dark","skin","tone"]}, + {"emoji":"🤦‍♂️","name":"man facepalming","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man","facepalming"]}, + {"emoji":"🤦🏻‍♂️","name":"man facepalming: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","facepalming:","light","skin","tone"]}, + {"emoji":"🤦🏼‍♂️","name":"man facepalming: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","facepalming:","medium-light","skin","tone"]}, + {"emoji":"🤦🏽‍♂️","name":"man facepalming: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","facepalming:","medium","skin","tone"]}, + {"emoji":"🤦🏾‍♂️","name":"man facepalming: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","facepalming:","medium-dark","skin","tone"]}, + {"emoji":"🤦🏿‍♂️","name":"man facepalming: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","facepalming:","dark","skin","tone"]}, + {"emoji":"🤦‍♀️","name":"woman facepalming","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman","facepalming"]}, + {"emoji":"🤦🏻‍♀️","name":"woman facepalming: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","facepalming:","light","skin","tone"]}, + {"emoji":"🤦🏼‍♀️","name":"woman facepalming: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","facepalming:","medium-light","skin","tone"]}, + {"emoji":"🤦🏽‍♀️","name":"woman facepalming: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","facepalming:","medium","skin","tone"]}, + {"emoji":"🤦🏾‍♀️","name":"woman facepalming: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","facepalming:","medium-dark","skin","tone"]}, + {"emoji":"🤦🏿‍♀️","name":"woman facepalming: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","facepalming:","dark","skin","tone"]}, + {"emoji":"🤷","name":"person shrugging","category":"Smileys & People","version":"3.0","skinTone":false,"keywords":["person","shrugging","doubt","dunno","guess","idk","ignorance","indifference","knows","maybe","shrug","whatever","who"]}, + {"emoji":"🤷🏻","name":"person shrugging: light skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["person","shrugging:","light","skin","tone"]}, + {"emoji":"🤷🏼","name":"person shrugging: medium-light skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["person","shrugging:","medium-light","skin","tone"]}, + {"emoji":"🤷🏽","name":"person shrugging: medium skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["person","shrugging:","medium","skin","tone"]}, + {"emoji":"🤷🏾","name":"person shrugging: medium-dark skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["person","shrugging:","medium-dark","skin","tone"]}, + {"emoji":"🤷🏿","name":"person shrugging: dark skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["person","shrugging:","dark","skin","tone"]}, + {"emoji":"🤷‍♂️","name":"man shrugging","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man","shrugging"]}, + {"emoji":"🤷🏻‍♂️","name":"man shrugging: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","shrugging:","light","skin","tone"]}, + {"emoji":"🤷🏼‍♂️","name":"man shrugging: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","shrugging:","medium-light","skin","tone"]}, + {"emoji":"🤷🏽‍♂️","name":"man shrugging: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","shrugging:","medium","skin","tone"]}, + {"emoji":"🤷🏾‍♂️","name":"man shrugging: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","shrugging:","medium-dark","skin","tone"]}, + {"emoji":"🤷🏿‍♂️","name":"man shrugging: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","shrugging:","dark","skin","tone"]}, + {"emoji":"🤷‍♀️","name":"woman shrugging","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman","shrugging"]}, + {"emoji":"🤷🏻‍♀️","name":"woman shrugging: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","shrugging:","light","skin","tone"]}, + {"emoji":"🤷🏼‍♀️","name":"woman shrugging: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","shrugging:","medium-light","skin","tone"]}, + {"emoji":"🤷🏽‍♀️","name":"woman shrugging: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","shrugging:","medium","skin","tone"]}, + {"emoji":"🤷🏾‍♀️","name":"woman shrugging: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","shrugging:","medium-dark","skin","tone"]}, + {"emoji":"🤷🏿‍♀️","name":"woman shrugging: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","shrugging:","dark","skin","tone"]}, + {"emoji":"🧑‍⚕️","name":"health worker","category":"Smileys & People","version":"12.1","skinTone":false,"keywords":["health","worker"]}, + {"emoji":"🧑🏻‍⚕️","name":"health worker: light skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["health","worker:","light","skin","tone"]}, + {"emoji":"🧑🏼‍⚕️","name":"health worker: medium-light skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["health","worker:","medium-light","skin","tone"]}, + {"emoji":"🧑🏽‍⚕️","name":"health worker: medium skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["health","worker:","medium","skin","tone"]}, + {"emoji":"🧑🏾‍⚕️","name":"health worker: medium-dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["health","worker:","medium-dark","skin","tone"]}, + {"emoji":"🧑🏿‍⚕️","name":"health worker: dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["health","worker:","dark","skin","tone"]}, + {"emoji":"👨‍⚕️","name":"man health worker","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man","health","worker"]}, + {"emoji":"👨🏻‍⚕️","name":"man health worker: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","health","worker:","light","skin","tone"]}, + {"emoji":"👨🏼‍⚕️","name":"man health worker: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","health","worker:","medium-light","skin","tone"]}, + {"emoji":"👨🏽‍⚕️","name":"man health worker: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","health","worker:","medium","skin","tone"]}, + {"emoji":"👨🏾‍⚕️","name":"man health worker: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","health","worker:","medium-dark","skin","tone"]}, + {"emoji":"👨🏿‍⚕️","name":"man health worker: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","health","worker:","dark","skin","tone"]}, + {"emoji":"👩‍⚕️","name":"woman health worker","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman","health","worker"]}, + {"emoji":"👩🏻‍⚕️","name":"woman health worker: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","health","worker:","light","skin","tone"]}, + {"emoji":"👩🏼‍⚕️","name":"woman health worker: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","health","worker:","medium-light","skin","tone"]}, + {"emoji":"👩🏽‍⚕️","name":"woman health worker: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","health","worker:","medium","skin","tone"]}, + {"emoji":"👩🏾‍⚕️","name":"woman health worker: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","health","worker:","medium-dark","skin","tone"]}, + {"emoji":"👩🏿‍⚕️","name":"woman health worker: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","health","worker:","dark","skin","tone"]}, + {"emoji":"🧑‍🎓","name":"student","category":"Smileys & People","version":"12.1","skinTone":false,"keywords":["student","graduate"]}, + {"emoji":"🧑🏻‍🎓","name":"student: light skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["student:","light","skin","tone"]}, + {"emoji":"🧑🏼‍🎓","name":"student: medium-light skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["student:","medium-light","skin","tone"]}, + {"emoji":"🧑🏽‍🎓","name":"student: medium skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["student:","medium","skin","tone"]}, + {"emoji":"🧑🏾‍🎓","name":"student: medium-dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["student:","medium-dark","skin","tone"]}, + {"emoji":"🧑🏿‍🎓","name":"student: dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["student:","dark","skin","tone"]}, + {"emoji":"👨‍🎓","name":"man student","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man","student","graduate"]}, + {"emoji":"👨🏻‍🎓","name":"man student: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","student:","light","skin","tone"]}, + {"emoji":"👨🏼‍🎓","name":"man student: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","student:","medium-light","skin","tone"]}, + {"emoji":"👨🏽‍🎓","name":"man student: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","student:","medium","skin","tone"]}, + {"emoji":"👨🏾‍🎓","name":"man student: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","student:","medium-dark","skin","tone"]}, + {"emoji":"👨🏿‍🎓","name":"man student: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","student:","dark","skin","tone"]}, + {"emoji":"👩‍🎓","name":"woman student","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman","student","graduate"]}, + {"emoji":"👩🏻‍🎓","name":"woman student: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","student:","light","skin","tone"]}, + {"emoji":"👩🏼‍🎓","name":"woman student: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","student:","medium-light","skin","tone"]}, + {"emoji":"👩🏽‍🎓","name":"woman student: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","student:","medium","skin","tone"]}, + {"emoji":"👩🏾‍🎓","name":"woman student: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","student:","medium-dark","skin","tone"]}, + {"emoji":"👩🏿‍🎓","name":"woman student: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","student:","dark","skin","tone"]}, + {"emoji":"🧑‍🏫","name":"teacher","category":"Smileys & People","version":"12.1","skinTone":false,"keywords":["teacher","instructor","lecturer","professor"]}, + {"emoji":"🧑🏻‍🏫","name":"teacher: light skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["teacher:","light","skin","tone"]}, + {"emoji":"🧑🏼‍🏫","name":"teacher: medium-light skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["teacher:","medium-light","skin","tone"]}, + {"emoji":"🧑🏽‍🏫","name":"teacher: medium skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["teacher:","medium","skin","tone"]}, + {"emoji":"🧑🏾‍🏫","name":"teacher: medium-dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["teacher:","medium-dark","skin","tone"]}, + {"emoji":"🧑🏿‍🏫","name":"teacher: dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["teacher:","dark","skin","tone"]}, + {"emoji":"👨‍🏫","name":"man teacher","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man","teacher","instructor","lecturer","professor"]}, + {"emoji":"👨🏻‍🏫","name":"man teacher: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","teacher:","light","skin","tone"]}, + {"emoji":"👨🏼‍🏫","name":"man teacher: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","teacher:","medium-light","skin","tone"]}, + {"emoji":"👨🏽‍🏫","name":"man teacher: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","teacher:","medium","skin","tone"]}, + {"emoji":"👨🏾‍🏫","name":"man teacher: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","teacher:","medium-dark","skin","tone"]}, + {"emoji":"👨🏿‍🏫","name":"man teacher: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","teacher:","dark","skin","tone"]}, + {"emoji":"👩‍🏫","name":"woman teacher","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman","teacher","instructor","lecturer","professor"]}, + {"emoji":"👩🏻‍🏫","name":"woman teacher: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","teacher:","light","skin","tone"]}, + {"emoji":"👩🏼‍🏫","name":"woman teacher: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","teacher:","medium-light","skin","tone"]}, + {"emoji":"👩🏽‍🏫","name":"woman teacher: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","teacher:","medium","skin","tone"]}, + {"emoji":"👩🏾‍🏫","name":"woman teacher: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","teacher:","medium-dark","skin","tone"]}, + {"emoji":"👩🏿‍🏫","name":"woman teacher: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","teacher:","dark","skin","tone"]}, + {"emoji":"🧑‍⚖️","name":"judge","category":"Smileys & People","version":"12.1","skinTone":false,"keywords":["judge"]}, + {"emoji":"🧑🏻‍⚖️","name":"judge: light skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["judge:","light","skin","tone"]}, + {"emoji":"🧑🏼‍⚖️","name":"judge: medium-light skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["judge:","medium-light","skin","tone"]}, + {"emoji":"🧑🏽‍⚖️","name":"judge: medium skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["judge:","medium","skin","tone"]}, + {"emoji":"🧑🏾‍⚖️","name":"judge: medium-dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["judge:","medium-dark","skin","tone"]}, + {"emoji":"🧑🏿‍⚖️","name":"judge: dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["judge:","dark","skin","tone"]}, + {"emoji":"👨‍⚖️","name":"man judge","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man","judge"]}, + {"emoji":"👨🏻‍⚖️","name":"man judge: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","judge:","light","skin","tone"]}, + {"emoji":"👨🏼‍⚖️","name":"man judge: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","judge:","medium-light","skin","tone"]}, + {"emoji":"👨🏽‍⚖️","name":"man judge: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","judge:","medium","skin","tone"]}, + {"emoji":"👨🏾‍⚖️","name":"man judge: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","judge:","medium-dark","skin","tone"]}, + {"emoji":"👨🏿‍⚖️","name":"man judge: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","judge:","dark","skin","tone"]}, + {"emoji":"👩‍⚖️","name":"woman judge","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman","judge"]}, + {"emoji":"👩🏻‍⚖️","name":"woman judge: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","judge:","light","skin","tone"]}, + {"emoji":"👩🏼‍⚖️","name":"woman judge: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","judge:","medium-light","skin","tone"]}, + {"emoji":"👩🏽‍⚖️","name":"woman judge: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","judge:","medium","skin","tone"]}, + {"emoji":"👩🏾‍⚖️","name":"woman judge: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","judge:","medium-dark","skin","tone"]}, + {"emoji":"👩🏿‍⚖️","name":"woman judge: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","judge:","dark","skin","tone"]}, + {"emoji":"🧑‍🌾","name":"farmer","category":"Smileys & People","version":"12.1","skinTone":false,"keywords":["farmer","gardener","rancher"]}, + {"emoji":"🧑🏻‍🌾","name":"farmer: light skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["farmer:","light","skin","tone"]}, + {"emoji":"🧑🏼‍🌾","name":"farmer: medium-light skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["farmer:","medium-light","skin","tone"]}, + {"emoji":"🧑🏽‍🌾","name":"farmer: medium skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["farmer:","medium","skin","tone"]}, + {"emoji":"🧑🏾‍🌾","name":"farmer: medium-dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["farmer:","medium-dark","skin","tone"]}, + {"emoji":"🧑🏿‍🌾","name":"farmer: dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["farmer:","dark","skin","tone"]}, + {"emoji":"👨‍🌾","name":"man farmer","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man","farmer","gardener","rancher"]}, + {"emoji":"👨🏻‍🌾","name":"man farmer: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","farmer:","light","skin","tone"]}, + {"emoji":"👨🏼‍🌾","name":"man farmer: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","farmer:","medium-light","skin","tone"]}, + {"emoji":"👨🏽‍🌾","name":"man farmer: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","farmer:","medium","skin","tone"]}, + {"emoji":"👨🏾‍🌾","name":"man farmer: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","farmer:","medium-dark","skin","tone"]}, + {"emoji":"👨🏿‍🌾","name":"man farmer: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","farmer:","dark","skin","tone"]}, + {"emoji":"👩‍🌾","name":"woman farmer","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman","farmer","gardener","rancher"]}, + {"emoji":"👩🏻‍🌾","name":"woman farmer: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","farmer:","light","skin","tone"]}, + {"emoji":"👩🏼‍🌾","name":"woman farmer: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","farmer:","medium-light","skin","tone"]}, + {"emoji":"👩🏽‍🌾","name":"woman farmer: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","farmer:","medium","skin","tone"]}, + {"emoji":"👩🏾‍🌾","name":"woman farmer: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","farmer:","medium-dark","skin","tone"]}, + {"emoji":"👩🏿‍🌾","name":"woman farmer: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","farmer:","dark","skin","tone"]}, + {"emoji":"🧑‍🍳","name":"cook","category":"Smileys & People","version":"12.1","skinTone":false,"keywords":["cook","chef"]}, + {"emoji":"🧑🏻‍🍳","name":"cook: light skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["cook:","light","skin","tone"]}, + {"emoji":"🧑🏼‍🍳","name":"cook: medium-light skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["cook:","medium-light","skin","tone"]}, + {"emoji":"🧑🏽‍🍳","name":"cook: medium skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["cook:","medium","skin","tone"]}, + {"emoji":"🧑🏾‍🍳","name":"cook: medium-dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["cook:","medium-dark","skin","tone"]}, + {"emoji":"🧑🏿‍🍳","name":"cook: dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["cook:","dark","skin","tone"]}, + {"emoji":"👨‍🍳","name":"man cook","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man","cook","chef"]}, + {"emoji":"👨🏻‍🍳","name":"man cook: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","cook:","light","skin","tone"]}, + {"emoji":"👨🏼‍🍳","name":"man cook: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","cook:","medium-light","skin","tone"]}, + {"emoji":"👨🏽‍🍳","name":"man cook: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","cook:","medium","skin","tone"]}, + {"emoji":"👨🏾‍🍳","name":"man cook: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","cook:","medium-dark","skin","tone"]}, + {"emoji":"👨🏿‍🍳","name":"man cook: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","cook:","dark","skin","tone"]}, + {"emoji":"👩‍🍳","name":"woman cook","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman","cook","chef"]}, + {"emoji":"👩🏻‍🍳","name":"woman cook: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","cook:","light","skin","tone"]}, + {"emoji":"👩🏼‍🍳","name":"woman cook: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","cook:","medium-light","skin","tone"]}, + {"emoji":"👩🏽‍🍳","name":"woman cook: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","cook:","medium","skin","tone"]}, + {"emoji":"👩🏾‍🍳","name":"woman cook: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","cook:","medium-dark","skin","tone"]}, + {"emoji":"👩🏿‍🍳","name":"woman cook: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","cook:","dark","skin","tone"]}, + {"emoji":"🧑‍🔧","name":"mechanic","category":"Smileys & People","version":"12.1","skinTone":false,"keywords":["mechanic","electrician","plumber","tradesperson"]}, + {"emoji":"🧑🏻‍🔧","name":"mechanic: light skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["mechanic:","light","skin","tone"]}, + {"emoji":"🧑🏼‍🔧","name":"mechanic: medium-light skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["mechanic:","medium-light","skin","tone"]}, + {"emoji":"🧑🏽‍🔧","name":"mechanic: medium skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["mechanic:","medium","skin","tone"]}, + {"emoji":"🧑🏾‍🔧","name":"mechanic: medium-dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["mechanic:","medium-dark","skin","tone"]}, + {"emoji":"🧑🏿‍🔧","name":"mechanic: dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["mechanic:","dark","skin","tone"]}, + {"emoji":"👨‍🔧","name":"man mechanic","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man","mechanic","electrician","plumber","tradesperson"]}, + {"emoji":"👨🏻‍🔧","name":"man mechanic: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","mechanic:","light","skin","tone"]}, + {"emoji":"👨🏼‍🔧","name":"man mechanic: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","mechanic:","medium-light","skin","tone"]}, + {"emoji":"👨🏽‍🔧","name":"man mechanic: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","mechanic:","medium","skin","tone"]}, + {"emoji":"👨🏾‍🔧","name":"man mechanic: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","mechanic:","medium-dark","skin","tone"]}, + {"emoji":"👨🏿‍🔧","name":"man mechanic: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","mechanic:","dark","skin","tone"]}, + {"emoji":"👩‍🔧","name":"woman mechanic","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman","mechanic","electrician","plumber","tradesperson"]}, + {"emoji":"👩🏻‍🔧","name":"woman mechanic: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","mechanic:","light","skin","tone"]}, + {"emoji":"👩🏼‍🔧","name":"woman mechanic: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","mechanic:","medium-light","skin","tone"]}, + {"emoji":"👩🏽‍🔧","name":"woman mechanic: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","mechanic:","medium","skin","tone"]}, + {"emoji":"👩🏾‍🔧","name":"woman mechanic: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","mechanic:","medium-dark","skin","tone"]}, + {"emoji":"👩🏿‍🔧","name":"woman mechanic: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","mechanic:","dark","skin","tone"]}, + {"emoji":"🧑‍🏭","name":"factory worker","category":"Smileys & People","version":"12.1","skinTone":false,"keywords":["factory","worker","assembly","industrial"]}, + {"emoji":"🧑🏻‍🏭","name":"factory worker: light skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["factory","worker:","light","skin","tone"]}, + {"emoji":"🧑🏼‍🏭","name":"factory worker: medium-light skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["factory","worker:","medium-light","skin","tone"]}, + {"emoji":"🧑🏽‍🏭","name":"factory worker: medium skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["factory","worker:","medium","skin","tone"]}, + {"emoji":"🧑🏾‍🏭","name":"factory worker: medium-dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["factory","worker:","medium-dark","skin","tone"]}, + {"emoji":"🧑🏿‍🏭","name":"factory worker: dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["factory","worker:","dark","skin","tone"]}, + {"emoji":"👨‍🏭","name":"man factory worker","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man","factory","worker","assembly","industrial"]}, + {"emoji":"👨🏻‍🏭","name":"man factory worker: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","factory","worker:","light","skin","tone"]}, + {"emoji":"👨🏼‍🏭","name":"man factory worker: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","factory","worker:","medium-light","skin","tone"]}, + {"emoji":"👨🏽‍🏭","name":"man factory worker: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","factory","worker:","medium","skin","tone"]}, + {"emoji":"👨🏾‍🏭","name":"man factory worker: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","factory","worker:","medium-dark","skin","tone"]}, + {"emoji":"👨🏿‍🏭","name":"man factory worker: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","factory","worker:","dark","skin","tone"]}, + {"emoji":"👩‍🏭","name":"woman factory worker","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman","factory","worker","assembly","industrial"]}, + {"emoji":"👩🏻‍🏭","name":"woman factory worker: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","factory","worker:","light","skin","tone"]}, + {"emoji":"👩🏼‍🏭","name":"woman factory worker: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","factory","worker:","medium-light","skin","tone"]}, + {"emoji":"👩🏽‍🏭","name":"woman factory worker: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","factory","worker:","medium","skin","tone"]}, + {"emoji":"👩🏾‍🏭","name":"woman factory worker: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","factory","worker:","medium-dark","skin","tone"]}, + {"emoji":"👩🏿‍🏭","name":"woman factory worker: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","factory","worker:","dark","skin","tone"]}, + {"emoji":"🧑‍💼","name":"office worker","category":"Smileys & People","version":"12.1","skinTone":false,"keywords":["office","worker","architect","business","manager","white-collar"]}, + {"emoji":"🧑🏻‍💼","name":"office worker: light skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["office","worker:","light","skin","tone"]}, + {"emoji":"🧑🏼‍💼","name":"office worker: medium-light skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["office","worker:","medium-light","skin","tone"]}, + {"emoji":"🧑🏽‍💼","name":"office worker: medium skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["office","worker:","medium","skin","tone"]}, + {"emoji":"🧑🏾‍💼","name":"office worker: medium-dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["office","worker:","medium-dark","skin","tone"]}, + {"emoji":"🧑🏿‍💼","name":"office worker: dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["office","worker:","dark","skin","tone"]}, + {"emoji":"👨‍💼","name":"man office worker","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man","office","worker","architect","business","manager","white-collar"]}, + {"emoji":"👨🏻‍💼","name":"man office worker: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","office","worker:","light","skin","tone"]}, + {"emoji":"👨🏼‍💼","name":"man office worker: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","office","worker:","medium-light","skin","tone"]}, + {"emoji":"👨🏽‍💼","name":"man office worker: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","office","worker:","medium","skin","tone"]}, + {"emoji":"👨🏾‍💼","name":"man office worker: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","office","worker:","medium-dark","skin","tone"]}, + {"emoji":"👨🏿‍💼","name":"man office worker: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","office","worker:","dark","skin","tone"]}, + {"emoji":"👩‍💼","name":"woman office worker","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman","office","worker","architect","business","manager","white-collar"]}, + {"emoji":"👩🏻‍💼","name":"woman office worker: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","office","worker:","light","skin","tone"]}, + {"emoji":"👩🏼‍💼","name":"woman office worker: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","office","worker:","medium-light","skin","tone"]}, + {"emoji":"👩🏽‍💼","name":"woman office worker: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","office","worker:","medium","skin","tone"]}, + {"emoji":"👩🏾‍💼","name":"woman office worker: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","office","worker:","medium-dark","skin","tone"]}, + {"emoji":"👩🏿‍💼","name":"woman office worker: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","office","worker:","dark","skin","tone"]}, + {"emoji":"🧑‍🔬","name":"scientist","category":"Smileys & People","version":"12.1","skinTone":false,"keywords":["scientist","biologist","chemist","engineer","mathematician","physicist"]}, + {"emoji":"🧑🏻‍🔬","name":"scientist: light skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["scientist:","light","skin","tone"]}, + {"emoji":"🧑🏼‍🔬","name":"scientist: medium-light skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["scientist:","medium-light","skin","tone"]}, + {"emoji":"🧑🏽‍🔬","name":"scientist: medium skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["scientist:","medium","skin","tone"]}, + {"emoji":"🧑🏾‍🔬","name":"scientist: medium-dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["scientist:","medium-dark","skin","tone"]}, + {"emoji":"🧑🏿‍🔬","name":"scientist: dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["scientist:","dark","skin","tone"]}, + {"emoji":"👨‍🔬","name":"man scientist","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man","scientist","biologist","chemist","engineer","mathematician","physicist"]}, + {"emoji":"👨🏻‍🔬","name":"man scientist: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","scientist:","light","skin","tone"]}, + {"emoji":"👨🏼‍🔬","name":"man scientist: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","scientist:","medium-light","skin","tone"]}, + {"emoji":"👨🏽‍🔬","name":"man scientist: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","scientist:","medium","skin","tone"]}, + {"emoji":"👨🏾‍🔬","name":"man scientist: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","scientist:","medium-dark","skin","tone"]}, + {"emoji":"👨🏿‍🔬","name":"man scientist: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","scientist:","dark","skin","tone"]}, + {"emoji":"👩‍🔬","name":"woman scientist","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman","scientist","biologist","chemist","engineer","mathematician","physicist"]}, + {"emoji":"👩🏻‍🔬","name":"woman scientist: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","scientist:","light","skin","tone"]}, + {"emoji":"👩🏼‍🔬","name":"woman scientist: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","scientist:","medium-light","skin","tone"]}, + {"emoji":"👩🏽‍🔬","name":"woman scientist: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","scientist:","medium","skin","tone"]}, + {"emoji":"👩🏾‍🔬","name":"woman scientist: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","scientist:","medium-dark","skin","tone"]}, + {"emoji":"👩🏿‍🔬","name":"woman scientist: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","scientist:","dark","skin","tone"]}, + {"emoji":"🧑‍💻","name":"technologist","category":"Smileys & People","version":"12.1","skinTone":false,"keywords":["technologist","coder","computer","developer","inventor","software"]}, + {"emoji":"🧑🏻‍💻","name":"technologist: light skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["technologist:","light","skin","tone"]}, + {"emoji":"🧑🏼‍💻","name":"technologist: medium-light skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["technologist:","medium-light","skin","tone"]}, + {"emoji":"🧑🏽‍💻","name":"technologist: medium skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["technologist:","medium","skin","tone"]}, + {"emoji":"🧑🏾‍💻","name":"technologist: medium-dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["technologist:","medium-dark","skin","tone"]}, + {"emoji":"🧑🏿‍💻","name":"technologist: dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["technologist:","dark","skin","tone"]}, + {"emoji":"👨‍💻","name":"man technologist","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man","technologist","coder","computer","developer","inventor","software"]}, + {"emoji":"👨🏻‍💻","name":"man technologist: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","technologist:","light","skin","tone"]}, + {"emoji":"👨🏼‍💻","name":"man technologist: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","technologist:","medium-light","skin","tone"]}, + {"emoji":"👨🏽‍💻","name":"man technologist: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","technologist:","medium","skin","tone"]}, + {"emoji":"👨🏾‍💻","name":"man technologist: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","technologist:","medium-dark","skin","tone"]}, + {"emoji":"👨🏿‍💻","name":"man technologist: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","technologist:","dark","skin","tone"]}, + {"emoji":"👩‍💻","name":"woman technologist","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman","technologist","coder","computer","developer","inventor","software"]}, + {"emoji":"👩🏻‍💻","name":"woman technologist: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","technologist:","light","skin","tone"]}, + {"emoji":"👩🏼‍💻","name":"woman technologist: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","technologist:","medium-light","skin","tone"]}, + {"emoji":"👩🏽‍💻","name":"woman technologist: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","technologist:","medium","skin","tone"]}, + {"emoji":"👩🏾‍💻","name":"woman technologist: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","technologist:","medium-dark","skin","tone"]}, + {"emoji":"👩🏿‍💻","name":"woman technologist: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","technologist:","dark","skin","tone"]}, + {"emoji":"🧑‍🎤","name":"singer","category":"Smileys & People","version":"12.1","skinTone":false,"keywords":["singer","actor","entertainer","rock","rockstar","star"]}, + {"emoji":"🧑🏻‍🎤","name":"singer: light skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["singer:","light","skin","tone"]}, + {"emoji":"🧑🏼‍🎤","name":"singer: medium-light skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["singer:","medium-light","skin","tone"]}, + {"emoji":"🧑🏽‍🎤","name":"singer: medium skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["singer:","medium","skin","tone"]}, + {"emoji":"🧑🏾‍🎤","name":"singer: medium-dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["singer:","medium-dark","skin","tone"]}, + {"emoji":"🧑🏿‍🎤","name":"singer: dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["singer:","dark","skin","tone"]}, + {"emoji":"👨‍🎤","name":"man singer","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man","singer","actor","entertainer","rock","rockstar","star"]}, + {"emoji":"👨🏻‍🎤","name":"man singer: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","singer:","light","skin","tone"]}, + {"emoji":"👨🏼‍🎤","name":"man singer: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","singer:","medium-light","skin","tone"]}, + {"emoji":"👨🏽‍🎤","name":"man singer: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","singer:","medium","skin","tone"]}, + {"emoji":"👨🏾‍🎤","name":"man singer: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","singer:","medium-dark","skin","tone"]}, + {"emoji":"👨🏿‍🎤","name":"man singer: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","singer:","dark","skin","tone"]}, + {"emoji":"👩‍🎤","name":"woman singer","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman","singer","actor","entertainer","rock","rockstar","star"]}, + {"emoji":"👩🏻‍🎤","name":"woman singer: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","singer:","light","skin","tone"]}, + {"emoji":"👩🏼‍🎤","name":"woman singer: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","singer:","medium-light","skin","tone"]}, + {"emoji":"👩🏽‍🎤","name":"woman singer: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","singer:","medium","skin","tone"]}, + {"emoji":"👩🏾‍🎤","name":"woman singer: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","singer:","medium-dark","skin","tone"]}, + {"emoji":"👩🏿‍🎤","name":"woman singer: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","singer:","dark","skin","tone"]}, + {"emoji":"🧑‍🎨","name":"artist","category":"Smileys & People","version":"12.1","skinTone":false,"keywords":["artist","palette"]}, + {"emoji":"🧑🏻‍🎨","name":"artist: light skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["artist:","light","skin","tone"]}, + {"emoji":"🧑🏼‍🎨","name":"artist: medium-light skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["artist:","medium-light","skin","tone"]}, + {"emoji":"🧑🏽‍🎨","name":"artist: medium skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["artist:","medium","skin","tone"]}, + {"emoji":"🧑🏾‍🎨","name":"artist: medium-dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["artist:","medium-dark","skin","tone"]}, + {"emoji":"🧑🏿‍🎨","name":"artist: dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["artist:","dark","skin","tone"]}, + {"emoji":"👨‍🎨","name":"man artist","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man","artist","palette"]}, + {"emoji":"👨🏻‍🎨","name":"man artist: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","artist:","light","skin","tone"]}, + {"emoji":"👨🏼‍🎨","name":"man artist: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","artist:","medium-light","skin","tone"]}, + {"emoji":"👨🏽‍🎨","name":"man artist: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","artist:","medium","skin","tone"]}, + {"emoji":"👨🏾‍🎨","name":"man artist: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","artist:","medium-dark","skin","tone"]}, + {"emoji":"👨🏿‍🎨","name":"man artist: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","artist:","dark","skin","tone"]}, + {"emoji":"👩‍🎨","name":"woman artist","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman","artist","palette"]}, + {"emoji":"👩🏻‍🎨","name":"woman artist: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","artist:","light","skin","tone"]}, + {"emoji":"👩🏼‍🎨","name":"woman artist: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","artist:","medium-light","skin","tone"]}, + {"emoji":"👩🏽‍🎨","name":"woman artist: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","artist:","medium","skin","tone"]}, + {"emoji":"👩🏾‍🎨","name":"woman artist: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","artist:","medium-dark","skin","tone"]}, + {"emoji":"👩🏿‍🎨","name":"woman artist: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","artist:","dark","skin","tone"]}, + {"emoji":"🧑‍✈️","name":"pilot","category":"Smileys & People","version":"12.1","skinTone":false,"keywords":["pilot"]}, + {"emoji":"🧑🏻‍✈️","name":"pilot: light skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["pilot:","light","skin","tone"]}, + {"emoji":"🧑🏼‍✈️","name":"pilot: medium-light skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["pilot:","medium-light","skin","tone"]}, + {"emoji":"🧑🏽‍✈️","name":"pilot: medium skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["pilot:","medium","skin","tone"]}, + {"emoji":"🧑🏾‍✈️","name":"pilot: medium-dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["pilot:","medium-dark","skin","tone"]}, + {"emoji":"🧑🏿‍✈️","name":"pilot: dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["pilot:","dark","skin","tone"]}, + {"emoji":"👨‍✈️","name":"man pilot","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man","pilot"]}, + {"emoji":"👨🏻‍✈️","name":"man pilot: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","pilot:","light","skin","tone"]}, + {"emoji":"👨🏼‍✈️","name":"man pilot: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","pilot:","medium-light","skin","tone"]}, + {"emoji":"👨🏽‍✈️","name":"man pilot: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","pilot:","medium","skin","tone"]}, + {"emoji":"👨🏾‍✈️","name":"man pilot: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","pilot:","medium-dark","skin","tone"]}, + {"emoji":"👨🏿‍✈️","name":"man pilot: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","pilot:","dark","skin","tone"]}, + {"emoji":"👩‍✈️","name":"woman pilot","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman","pilot"]}, + {"emoji":"👩🏻‍✈️","name":"woman pilot: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","pilot:","light","skin","tone"]}, + {"emoji":"👩🏼‍✈️","name":"woman pilot: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","pilot:","medium-light","skin","tone"]}, + {"emoji":"👩🏽‍✈️","name":"woman pilot: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","pilot:","medium","skin","tone"]}, + {"emoji":"👩🏾‍✈️","name":"woman pilot: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","pilot:","medium-dark","skin","tone"]}, + {"emoji":"👩🏿‍✈️","name":"woman pilot: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","pilot:","dark","skin","tone"]}, + {"emoji":"🧑‍🚀","name":"astronaut","category":"Smileys & People","version":"12.1","skinTone":false,"keywords":["astronaut","rocket","space"]}, + {"emoji":"🧑🏻‍🚀","name":"astronaut: light skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["astronaut:","light","skin","tone"]}, + {"emoji":"🧑🏼‍🚀","name":"astronaut: medium-light skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["astronaut:","medium-light","skin","tone"]}, + {"emoji":"🧑🏽‍🚀","name":"astronaut: medium skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["astronaut:","medium","skin","tone"]}, + {"emoji":"🧑🏾‍🚀","name":"astronaut: medium-dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["astronaut:","medium-dark","skin","tone"]}, + {"emoji":"🧑🏿‍🚀","name":"astronaut: dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["astronaut:","dark","skin","tone"]}, + {"emoji":"👨‍🚀","name":"man astronaut","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man","astronaut","rocket","space"]}, + {"emoji":"👨🏻‍🚀","name":"man astronaut: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","astronaut:","light","skin","tone"]}, + {"emoji":"👨🏼‍🚀","name":"man astronaut: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","astronaut:","medium-light","skin","tone"]}, + {"emoji":"👨🏽‍🚀","name":"man astronaut: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","astronaut:","medium","skin","tone"]}, + {"emoji":"👨🏾‍🚀","name":"man astronaut: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","astronaut:","medium-dark","skin","tone"]}, + {"emoji":"👨🏿‍🚀","name":"man astronaut: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","astronaut:","dark","skin","tone"]}, + {"emoji":"👩‍🚀","name":"woman astronaut","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman","astronaut","rocket","space"]}, + {"emoji":"👩🏻‍🚀","name":"woman astronaut: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","astronaut:","light","skin","tone"]}, + {"emoji":"👩🏼‍🚀","name":"woman astronaut: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","astronaut:","medium-light","skin","tone"]}, + {"emoji":"👩🏽‍🚀","name":"woman astronaut: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","astronaut:","medium","skin","tone"]}, + {"emoji":"👩🏾‍🚀","name":"woman astronaut: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","astronaut:","medium-dark","skin","tone"]}, + {"emoji":"👩🏿‍🚀","name":"woman astronaut: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","astronaut:","dark","skin","tone"]}, + {"emoji":"🧑‍🚒","name":"firefighter","category":"Smileys & People","version":"12.1","skinTone":false,"keywords":["firefighter","fire","firetruck"]}, + {"emoji":"🧑🏻‍🚒","name":"firefighter: light skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["firefighter:","light","skin","tone"]}, + {"emoji":"🧑🏼‍🚒","name":"firefighter: medium-light skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["firefighter:","medium-light","skin","tone"]}, + {"emoji":"🧑🏽‍🚒","name":"firefighter: medium skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["firefighter:","medium","skin","tone"]}, + {"emoji":"🧑🏾‍🚒","name":"firefighter: medium-dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["firefighter:","medium-dark","skin","tone"]}, + {"emoji":"🧑🏿‍🚒","name":"firefighter: dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["firefighter:","dark","skin","tone"]}, + {"emoji":"👨‍🚒","name":"man firefighter","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man","firefighter","fire","firetruck"]}, + {"emoji":"👨🏻‍🚒","name":"man firefighter: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","firefighter:","light","skin","tone"]}, + {"emoji":"👨🏼‍🚒","name":"man firefighter: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","firefighter:","medium-light","skin","tone"]}, + {"emoji":"👨🏽‍🚒","name":"man firefighter: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","firefighter:","medium","skin","tone"]}, + {"emoji":"👨🏾‍🚒","name":"man firefighter: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","firefighter:","medium-dark","skin","tone"]}, + {"emoji":"👨🏿‍🚒","name":"man firefighter: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","firefighter:","dark","skin","tone"]}, + {"emoji":"👩‍🚒","name":"woman firefighter","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman","firefighter","fire","firetruck"]}, + {"emoji":"👩🏻‍🚒","name":"woman firefighter: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","firefighter:","light","skin","tone"]}, + {"emoji":"👩🏼‍🚒","name":"woman firefighter: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","firefighter:","medium-light","skin","tone"]}, + {"emoji":"👩🏽‍🚒","name":"woman firefighter: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","firefighter:","medium","skin","tone"]}, + {"emoji":"👩🏾‍🚒","name":"woman firefighter: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","firefighter:","medium-dark","skin","tone"]}, + {"emoji":"👩🏿‍🚒","name":"woman firefighter: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","firefighter:","dark","skin","tone"]}, + {"emoji":"👮","name":"police officer","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["police","officer","apprehend","arrest","citation","cop","law","over","pulled","undercover"]}, + {"emoji":"👮🏻","name":"police officer: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["police","officer:","light","skin","tone"]}, + {"emoji":"👮🏼","name":"police officer: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["police","officer:","medium-light","skin","tone"]}, + {"emoji":"👮🏽","name":"police officer: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["police","officer:","medium","skin","tone"]}, + {"emoji":"👮🏾","name":"police officer: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["police","officer:","medium-dark","skin","tone"]}, + {"emoji":"👮🏿","name":"police officer: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["police","officer:","dark","skin","tone"]}, + {"emoji":"👮‍♂️","name":"man police officer","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man","police","officer"]}, + {"emoji":"👮🏻‍♂️","name":"man police officer: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","police","officer:","light","skin","tone"]}, + {"emoji":"👮🏼‍♂️","name":"man police officer: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","police","officer:","medium-light","skin","tone"]}, + {"emoji":"👮🏽‍♂️","name":"man police officer: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","police","officer:","medium","skin","tone"]}, + {"emoji":"👮🏾‍♂️","name":"man police officer: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","police","officer:","medium-dark","skin","tone"]}, + {"emoji":"👮🏿‍♂️","name":"man police officer: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","police","officer:","dark","skin","tone"]}, + {"emoji":"👮‍♀️","name":"woman police officer","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman","police","officer"]}, + {"emoji":"👮🏻‍♀️","name":"woman police officer: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","police","officer:","light","skin","tone"]}, + {"emoji":"👮🏼‍♀️","name":"woman police officer: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","police","officer:","medium-light","skin","tone"]}, + {"emoji":"👮🏽‍♀️","name":"woman police officer: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","police","officer:","medium","skin","tone"]}, + {"emoji":"👮🏾‍♀️","name":"woman police officer: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","police","officer:","medium-dark","skin","tone"]}, + {"emoji":"👮🏿‍♀️","name":"woman police officer: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","police","officer:","dark","skin","tone"]}, + {"emoji":"🕵️","name":"detective","category":"Smileys & People","version":"0.7","skinTone":false,"keywords":["detective"]}, + {"emoji":"🕵🏻","name":"detective: light skin tone","category":"Smileys & People","version":"2.0","skinTone":true,"keywords":["detective:","light","skin","tone"]}, + {"emoji":"🕵🏼","name":"detective: medium-light skin tone","category":"Smileys & People","version":"2.0","skinTone":true,"keywords":["detective:","medium-light","skin","tone"]}, + {"emoji":"🕵🏽","name":"detective: medium skin tone","category":"Smileys & People","version":"2.0","skinTone":true,"keywords":["detective:","medium","skin","tone"]}, + {"emoji":"🕵🏾","name":"detective: medium-dark skin tone","category":"Smileys & People","version":"2.0","skinTone":true,"keywords":["detective:","medium-dark","skin","tone"]}, + {"emoji":"🕵🏿","name":"detective: dark skin tone","category":"Smileys & People","version":"2.0","skinTone":true,"keywords":["detective:","dark","skin","tone"]}, + {"emoji":"🕵️‍♂️","name":"man detective","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man","detective"]}, + {"emoji":"🕵🏻‍♂️","name":"man detective: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","detective:","light","skin","tone"]}, + {"emoji":"🕵🏼‍♂️","name":"man detective: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","detective:","medium-light","skin","tone"]}, + {"emoji":"🕵🏽‍♂️","name":"man detective: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","detective:","medium","skin","tone"]}, + {"emoji":"🕵🏾‍♂️","name":"man detective: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","detective:","medium-dark","skin","tone"]}, + {"emoji":"🕵🏿‍♂️","name":"man detective: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","detective:","dark","skin","tone"]}, + {"emoji":"🕵️‍♀️","name":"woman detective","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman","detective"]}, + {"emoji":"🕵🏻‍♀️","name":"woman detective: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","detective:","light","skin","tone"]}, + {"emoji":"🕵🏼‍♀️","name":"woman detective: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","detective:","medium-light","skin","tone"]}, + {"emoji":"🕵🏽‍♀️","name":"woman detective: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","detective:","medium","skin","tone"]}, + {"emoji":"🕵🏾‍♀️","name":"woman detective: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","detective:","medium-dark","skin","tone"]}, + {"emoji":"🕵🏿‍♀️","name":"woman detective: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","detective:","dark","skin","tone"]}, + {"emoji":"💂","name":"guard","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["guard","buckingham","helmet","london","palace"]}, + {"emoji":"💂🏻","name":"guard: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["guard:","light","skin","tone"]}, + {"emoji":"💂🏼","name":"guard: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["guard:","medium-light","skin","tone"]}, + {"emoji":"💂🏽","name":"guard: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["guard:","medium","skin","tone"]}, + {"emoji":"💂🏾","name":"guard: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["guard:","medium-dark","skin","tone"]}, + {"emoji":"💂🏿","name":"guard: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["guard:","dark","skin","tone"]}, + {"emoji":"💂‍♂️","name":"man guard","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man","guard"]}, + {"emoji":"💂🏻‍♂️","name":"man guard: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","guard:","light","skin","tone"]}, + {"emoji":"💂🏼‍♂️","name":"man guard: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","guard:","medium-light","skin","tone"]}, + {"emoji":"💂🏽‍♂️","name":"man guard: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","guard:","medium","skin","tone"]}, + {"emoji":"💂🏾‍♂️","name":"man guard: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","guard:","medium-dark","skin","tone"]}, + {"emoji":"💂🏿‍♂️","name":"man guard: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","guard:","dark","skin","tone"]}, + {"emoji":"💂‍♀️","name":"woman guard","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman","guard"]}, + {"emoji":"💂🏻‍♀️","name":"woman guard: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","guard:","light","skin","tone"]}, + {"emoji":"💂🏼‍♀️","name":"woman guard: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","guard:","medium-light","skin","tone"]}, + {"emoji":"💂🏽‍♀️","name":"woman guard: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","guard:","medium","skin","tone"]}, + {"emoji":"💂🏾‍♀️","name":"woman guard: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","guard:","medium-dark","skin","tone"]}, + {"emoji":"💂🏿‍♀️","name":"woman guard: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","guard:","dark","skin","tone"]}, + {"emoji":"🥷","name":"ninja","category":"Smileys & People","version":"13.0","skinTone":false,"keywords":["ninja","assassin","fight","fighter","hidden","person","secret","skills","sly","soldier","stealth","war"]}, + {"emoji":"🥷🏻","name":"ninja: light skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["ninja:","light","skin","tone"]}, + {"emoji":"🥷🏼","name":"ninja: medium-light skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["ninja:","medium-light","skin","tone"]}, + {"emoji":"🥷🏽","name":"ninja: medium skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["ninja:","medium","skin","tone"]}, + {"emoji":"🥷🏾","name":"ninja: medium-dark skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["ninja:","medium-dark","skin","tone"]}, + {"emoji":"🥷🏿","name":"ninja: dark skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["ninja:","dark","skin","tone"]}, + {"emoji":"👷","name":"construction worker","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["construction","worker","build","fix","hardhat","hat","man","person","rebuild","remodel","repair","work"]}, + {"emoji":"👷🏻","name":"construction worker: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["construction","worker:","light","skin","tone"]}, + {"emoji":"👷🏼","name":"construction worker: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["construction","worker:","medium-light","skin","tone"]}, + {"emoji":"👷🏽","name":"construction worker: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["construction","worker:","medium","skin","tone"]}, + {"emoji":"👷🏾","name":"construction worker: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["construction","worker:","medium-dark","skin","tone"]}, + {"emoji":"👷🏿","name":"construction worker: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["construction","worker:","dark","skin","tone"]}, + {"emoji":"👷‍♂️","name":"man construction worker","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man","construction","worker"]}, + {"emoji":"👷🏻‍♂️","name":"man construction worker: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","construction","worker:","light","skin","tone"]}, + {"emoji":"👷🏼‍♂️","name":"man construction worker: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","construction","worker:","medium-light","skin","tone"]}, + {"emoji":"👷🏽‍♂️","name":"man construction worker: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","construction","worker:","medium","skin","tone"]}, + {"emoji":"👷🏾‍♂️","name":"man construction worker: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","construction","worker:","medium-dark","skin","tone"]}, + {"emoji":"👷🏿‍♂️","name":"man construction worker: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","construction","worker:","dark","skin","tone"]}, + {"emoji":"👷‍♀️","name":"woman construction worker","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman","construction","worker"]}, + {"emoji":"👷🏻‍♀️","name":"woman construction worker: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","construction","worker:","light","skin","tone"]}, + {"emoji":"👷🏼‍♀️","name":"woman construction worker: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","construction","worker:","medium-light","skin","tone"]}, + {"emoji":"👷🏽‍♀️","name":"woman construction worker: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","construction","worker:","medium","skin","tone"]}, + {"emoji":"👷🏾‍♀️","name":"woman construction worker: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","construction","worker:","medium-dark","skin","tone"]}, + {"emoji":"👷🏿‍♀️","name":"woman construction worker: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","construction","worker:","dark","skin","tone"]}, + {"emoji":"🫅","name":"person with crown","category":"Smileys & People","version":"14.0","skinTone":false,"keywords":["person","with","crown","monarch","noble","regal","royal","royalty"]}, + {"emoji":"🫅🏻","name":"person with crown: light skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["person","with","crown:","light","skin","tone"]}, + {"emoji":"🫅🏼","name":"person with crown: medium-light skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["person","with","crown:","medium-light","skin","tone"]}, + {"emoji":"🫅🏽","name":"person with crown: medium skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["person","with","crown:","medium","skin","tone"]}, + {"emoji":"🫅🏾","name":"person with crown: medium-dark skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["person","with","crown:","medium-dark","skin","tone"]}, + {"emoji":"🫅🏿","name":"person with crown: dark skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["person","with","crown:","dark","skin","tone"]}, + {"emoji":"🤴","name":"prince","category":"Smileys & People","version":"3.0","skinTone":false,"keywords":["prince","crown","fairy","fairytale","fantasy","king","royal","royalty","tale"]}, + {"emoji":"🤴🏻","name":"prince: light skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["prince:","light","skin","tone"]}, + {"emoji":"🤴🏼","name":"prince: medium-light skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["prince:","medium-light","skin","tone"]}, + {"emoji":"🤴🏽","name":"prince: medium skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["prince:","medium","skin","tone"]}, + {"emoji":"🤴🏾","name":"prince: medium-dark skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["prince:","medium-dark","skin","tone"]}, + {"emoji":"🤴🏿","name":"prince: dark skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["prince:","dark","skin","tone"]}, + {"emoji":"👸","name":"princess","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["princess","crown","fairy","fairytale","fantasy","queen","royal","royalty","tale"]}, + {"emoji":"👸🏻","name":"princess: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["princess:","light","skin","tone"]}, + {"emoji":"👸🏼","name":"princess: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["princess:","medium-light","skin","tone"]}, + {"emoji":"👸🏽","name":"princess: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["princess:","medium","skin","tone"]}, + {"emoji":"👸🏾","name":"princess: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["princess:","medium-dark","skin","tone"]}, + {"emoji":"👸🏿","name":"princess: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["princess:","dark","skin","tone"]}, + {"emoji":"👳","name":"person wearing turban","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["person","wearing","turban"]}, + {"emoji":"👳🏻","name":"person wearing turban: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","wearing","turban:","light","skin","tone"]}, + {"emoji":"👳🏼","name":"person wearing turban: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","wearing","turban:","medium-light","skin","tone"]}, + {"emoji":"👳🏽","name":"person wearing turban: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","wearing","turban:","medium","skin","tone"]}, + {"emoji":"👳🏾","name":"person wearing turban: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","wearing","turban:","medium-dark","skin","tone"]}, + {"emoji":"👳🏿","name":"person wearing turban: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","wearing","turban:","dark","skin","tone"]}, + {"emoji":"👳‍♂️","name":"man wearing turban","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man","wearing","turban"]}, + {"emoji":"👳🏻‍♂️","name":"man wearing turban: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","wearing","turban:","light","skin","tone"]}, + {"emoji":"👳🏼‍♂️","name":"man wearing turban: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","wearing","turban:","medium-light","skin","tone"]}, + {"emoji":"👳🏽‍♂️","name":"man wearing turban: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","wearing","turban:","medium","skin","tone"]}, + {"emoji":"👳🏾‍♂️","name":"man wearing turban: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","wearing","turban:","medium-dark","skin","tone"]}, + {"emoji":"👳🏿‍♂️","name":"man wearing turban: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","wearing","turban:","dark","skin","tone"]}, + {"emoji":"👳‍♀️","name":"woman wearing turban","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman","wearing","turban"]}, + {"emoji":"👳🏻‍♀️","name":"woman wearing turban: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","wearing","turban:","light","skin","tone"]}, + {"emoji":"👳🏼‍♀️","name":"woman wearing turban: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","wearing","turban:","medium-light","skin","tone"]}, + {"emoji":"👳🏽‍♀️","name":"woman wearing turban: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","wearing","turban:","medium","skin","tone"]}, + {"emoji":"👳🏾‍♀️","name":"woman wearing turban: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","wearing","turban:","medium-dark","skin","tone"]}, + {"emoji":"👳🏿‍♀️","name":"woman wearing turban: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","wearing","turban:","dark","skin","tone"]}, + {"emoji":"👲","name":"person with skullcap","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["person","with","skullcap","cap","Chinese","gua","guapi","hat","mao","pi"]}, + {"emoji":"👲🏻","name":"person with skullcap: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","with","skullcap:","light","skin","tone"]}, + {"emoji":"👲🏼","name":"person with skullcap: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","with","skullcap:","medium-light","skin","tone"]}, + {"emoji":"👲🏽","name":"person with skullcap: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","with","skullcap:","medium","skin","tone"]}, + {"emoji":"👲🏾","name":"person with skullcap: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","with","skullcap:","medium-dark","skin","tone"]}, + {"emoji":"👲🏿","name":"person with skullcap: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","with","skullcap:","dark","skin","tone"]}, + {"emoji":"🧕","name":"woman with headscarf","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["woman","with","headscarf","bandana","head","hijab","kerchief","mantilla","tichel"]}, + {"emoji":"🧕🏻","name":"woman with headscarf: light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["woman","with","headscarf:","light","skin","tone"]}, + {"emoji":"🧕🏼","name":"woman with headscarf: medium-light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["woman","with","headscarf:","medium-light","skin","tone"]}, + {"emoji":"🧕🏽","name":"woman with headscarf: medium skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["woman","with","headscarf:","medium","skin","tone"]}, + {"emoji":"🧕🏾","name":"woman with headscarf: medium-dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["woman","with","headscarf:","medium-dark","skin","tone"]}, + {"emoji":"🧕🏿","name":"woman with headscarf: dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["woman","with","headscarf:","dark","skin","tone"]}, + {"emoji":"🤵","name":"person in tuxedo","category":"Smileys & People","version":"3.0","skinTone":false,"keywords":["person","in","tuxedo","formal","wedding"]}, + {"emoji":"🤵🏻","name":"person in tuxedo: light skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["person","in","tuxedo:","light","skin","tone"]}, + {"emoji":"🤵🏼","name":"person in tuxedo: medium-light skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["person","in","tuxedo:","medium-light","skin","tone"]}, + {"emoji":"🤵🏽","name":"person in tuxedo: medium skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["person","in","tuxedo:","medium","skin","tone"]}, + {"emoji":"🤵🏾","name":"person in tuxedo: medium-dark skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["person","in","tuxedo:","medium-dark","skin","tone"]}, + {"emoji":"🤵🏿","name":"person in tuxedo: dark skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["person","in","tuxedo:","dark","skin","tone"]}, + {"emoji":"🤵‍♂️","name":"man in tuxedo","category":"Smileys & People","version":"13.0","skinTone":false,"keywords":["man","in","tuxedo"]}, + {"emoji":"🤵🏻‍♂️","name":"man in tuxedo: light skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["man","in","tuxedo:","light","skin","tone"]}, + {"emoji":"🤵🏼‍♂️","name":"man in tuxedo: medium-light skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["man","in","tuxedo:","medium-light","skin","tone"]}, + {"emoji":"🤵🏽‍♂️","name":"man in tuxedo: medium skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["man","in","tuxedo:","medium","skin","tone"]}, + {"emoji":"🤵🏾‍♂️","name":"man in tuxedo: medium-dark skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["man","in","tuxedo:","medium-dark","skin","tone"]}, + {"emoji":"🤵🏿‍♂️","name":"man in tuxedo: dark skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["man","in","tuxedo:","dark","skin","tone"]}, + {"emoji":"🤵‍♀️","name":"woman in tuxedo","category":"Smileys & People","version":"13.0","skinTone":false,"keywords":["woman","in","tuxedo"]}, + {"emoji":"🤵🏻‍♀️","name":"woman in tuxedo: light skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["woman","in","tuxedo:","light","skin","tone"]}, + {"emoji":"🤵🏼‍♀️","name":"woman in tuxedo: medium-light skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["woman","in","tuxedo:","medium-light","skin","tone"]}, + {"emoji":"🤵🏽‍♀️","name":"woman in tuxedo: medium skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["woman","in","tuxedo:","medium","skin","tone"]}, + {"emoji":"🤵🏾‍♀️","name":"woman in tuxedo: medium-dark skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["woman","in","tuxedo:","medium-dark","skin","tone"]}, + {"emoji":"🤵🏿‍♀️","name":"woman in tuxedo: dark skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["woman","in","tuxedo:","dark","skin","tone"]}, + {"emoji":"👰","name":"person with veil","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["person","with","veil","wedding"]}, + {"emoji":"👰🏻","name":"person with veil: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","with","veil:","light","skin","tone"]}, + {"emoji":"👰🏼","name":"person with veil: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","with","veil:","medium-light","skin","tone"]}, + {"emoji":"👰🏽","name":"person with veil: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","with","veil:","medium","skin","tone"]}, + {"emoji":"👰🏾","name":"person with veil: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","with","veil:","medium-dark","skin","tone"]}, + {"emoji":"👰🏿","name":"person with veil: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","with","veil:","dark","skin","tone"]}, + {"emoji":"👰‍♂️","name":"man with veil","category":"Smileys & People","version":"13.0","skinTone":false,"keywords":["man","with","veil"]}, + {"emoji":"👰🏻‍♂️","name":"man with veil: light skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["man","with","veil:","light","skin","tone"]}, + {"emoji":"👰🏼‍♂️","name":"man with veil: medium-light skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["man","with","veil:","medium-light","skin","tone"]}, + {"emoji":"👰🏽‍♂️","name":"man with veil: medium skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["man","with","veil:","medium","skin","tone"]}, + {"emoji":"👰🏾‍♂️","name":"man with veil: medium-dark skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["man","with","veil:","medium-dark","skin","tone"]}, + {"emoji":"👰🏿‍♂️","name":"man with veil: dark skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["man","with","veil:","dark","skin","tone"]}, + {"emoji":"👰‍♀️","name":"woman with veil","category":"Smileys & People","version":"13.0","skinTone":false,"keywords":["woman","with","veil"]}, + {"emoji":"👰🏻‍♀️","name":"woman with veil: light skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["woman","with","veil:","light","skin","tone"]}, + {"emoji":"👰🏼‍♀️","name":"woman with veil: medium-light skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["woman","with","veil:","medium-light","skin","tone"]}, + {"emoji":"👰🏽‍♀️","name":"woman with veil: medium skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["woman","with","veil:","medium","skin","tone"]}, + {"emoji":"👰🏾‍♀️","name":"woman with veil: medium-dark skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["woman","with","veil:","medium-dark","skin","tone"]}, + {"emoji":"👰🏿‍♀️","name":"woman with veil: dark skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["woman","with","veil:","dark","skin","tone"]}, + {"emoji":"🤰","name":"pregnant woman","category":"Smileys & People","version":"3.0","skinTone":false,"keywords":["pregnant","woman"]}, + {"emoji":"🤰🏻","name":"pregnant woman: light skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["pregnant","woman:","light","skin","tone"]}, + {"emoji":"🤰🏼","name":"pregnant woman: medium-light skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["pregnant","woman:","medium-light","skin","tone"]}, + {"emoji":"🤰🏽","name":"pregnant woman: medium skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["pregnant","woman:","medium","skin","tone"]}, + {"emoji":"🤰🏾","name":"pregnant woman: medium-dark skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["pregnant","woman:","medium-dark","skin","tone"]}, + {"emoji":"🤰🏿","name":"pregnant woman: dark skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["pregnant","woman:","dark","skin","tone"]}, + {"emoji":"🫃","name":"pregnant man","category":"Smileys & People","version":"14.0","skinTone":false,"keywords":["pregnant","man","belly","bloated","full","overeat"]}, + {"emoji":"🫃🏻","name":"pregnant man: light skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["pregnant","man:","light","skin","tone"]}, + {"emoji":"🫃🏼","name":"pregnant man: medium-light skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["pregnant","man:","medium-light","skin","tone"]}, + {"emoji":"🫃🏽","name":"pregnant man: medium skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["pregnant","man:","medium","skin","tone"]}, + {"emoji":"🫃🏾","name":"pregnant man: medium-dark skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["pregnant","man:","medium-dark","skin","tone"]}, + {"emoji":"🫃🏿","name":"pregnant man: dark skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["pregnant","man:","dark","skin","tone"]}, + {"emoji":"🫄","name":"pregnant person","category":"Smileys & People","version":"14.0","skinTone":false,"keywords":["pregnant","person","belly","bloated","full","overeat","stuffed"]}, + {"emoji":"🫄🏻","name":"pregnant person: light skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["pregnant","person:","light","skin","tone"]}, + {"emoji":"🫄🏼","name":"pregnant person: medium-light skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["pregnant","person:","medium-light","skin","tone"]}, + {"emoji":"🫄🏽","name":"pregnant person: medium skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["pregnant","person:","medium","skin","tone"]}, + {"emoji":"🫄🏾","name":"pregnant person: medium-dark skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["pregnant","person:","medium-dark","skin","tone"]}, + {"emoji":"🫄🏿","name":"pregnant person: dark skin tone","category":"Smileys & People","version":"14.0","skinTone":true,"keywords":["pregnant","person:","dark","skin","tone"]}, + {"emoji":"🤱","name":"breast-feeding","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["breast-feeding","baby","breast","feeding","mom","mother","nursing","woman"]}, + {"emoji":"🤱🏻","name":"breast-feeding: light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["breast-feeding:","light","skin","tone"]}, + {"emoji":"🤱🏼","name":"breast-feeding: medium-light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["breast-feeding:","medium-light","skin","tone"]}, + {"emoji":"🤱🏽","name":"breast-feeding: medium skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["breast-feeding:","medium","skin","tone"]}, + {"emoji":"🤱🏾","name":"breast-feeding: medium-dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["breast-feeding:","medium-dark","skin","tone"]}, + {"emoji":"🤱🏿","name":"breast-feeding: dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["breast-feeding:","dark","skin","tone"]}, + {"emoji":"👩‍🍼","name":"woman feeding baby","category":"Smileys & People","version":"13.0","skinTone":false,"keywords":["woman","feeding","baby","feed","mom","mother","nanny","newborn","nursing"]}, + {"emoji":"👩🏻‍🍼","name":"woman feeding baby: light skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["woman","feeding","baby:","light","skin","tone"]}, + {"emoji":"👩🏼‍🍼","name":"woman feeding baby: medium-light skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["woman","feeding","baby:","medium-light","skin","tone"]}, + {"emoji":"👩🏽‍🍼","name":"woman feeding baby: medium skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["woman","feeding","baby:","medium","skin","tone"]}, + {"emoji":"👩🏾‍🍼","name":"woman feeding baby: medium-dark skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["woman","feeding","baby:","medium-dark","skin","tone"]}, + {"emoji":"👩🏿‍🍼","name":"woman feeding baby: dark skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["woman","feeding","baby:","dark","skin","tone"]}, + {"emoji":"👨‍🍼","name":"man feeding baby","category":"Smileys & People","version":"13.0","skinTone":false,"keywords":["man","feeding","baby","dad","father","feed","nanny","newborn","nursing"]}, + {"emoji":"👨🏻‍🍼","name":"man feeding baby: light skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["man","feeding","baby:","light","skin","tone"]}, + {"emoji":"👨🏼‍🍼","name":"man feeding baby: medium-light skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["man","feeding","baby:","medium-light","skin","tone"]}, + {"emoji":"👨🏽‍🍼","name":"man feeding baby: medium skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["man","feeding","baby:","medium","skin","tone"]}, + {"emoji":"👨🏾‍🍼","name":"man feeding baby: medium-dark skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["man","feeding","baby:","medium-dark","skin","tone"]}, + {"emoji":"👨🏿‍🍼","name":"man feeding baby: dark skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["man","feeding","baby:","dark","skin","tone"]}, + {"emoji":"🧑‍🍼","name":"person feeding baby","category":"Smileys & People","version":"13.0","skinTone":false,"keywords":["person","feeding","baby","feed","nanny","newborn","nursing","parent"]}, + {"emoji":"🧑🏻‍🍼","name":"person feeding baby: light skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["person","feeding","baby:","light","skin","tone"]}, + {"emoji":"🧑🏼‍🍼","name":"person feeding baby: medium-light skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["person","feeding","baby:","medium-light","skin","tone"]}, + {"emoji":"🧑🏽‍🍼","name":"person feeding baby: medium skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["person","feeding","baby:","medium","skin","tone"]}, + {"emoji":"🧑🏾‍🍼","name":"person feeding baby: medium-dark skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["person","feeding","baby:","medium-dark","skin","tone"]}, + {"emoji":"🧑🏿‍🍼","name":"person feeding baby: dark skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["person","feeding","baby:","dark","skin","tone"]}, + {"emoji":"👼","name":"baby angel","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["baby","angel","church","face","fairy","fairytale","fantasy","tale"]}, + {"emoji":"👼🏻","name":"baby angel: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["baby","angel:","light","skin","tone"]}, + {"emoji":"👼🏼","name":"baby angel: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["baby","angel:","medium-light","skin","tone"]}, + {"emoji":"👼🏽","name":"baby angel: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["baby","angel:","medium","skin","tone"]}, + {"emoji":"👼🏾","name":"baby angel: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["baby","angel:","medium-dark","skin","tone"]}, + {"emoji":"👼🏿","name":"baby angel: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["baby","angel:","dark","skin","tone"]}, + {"emoji":"🎅","name":"Santa Claus","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["Santa","Claus","celebration","Christmas","claus","fairy","fantasy","father","holiday","merry","santa","tale","xmas"]}, + {"emoji":"🎅🏻","name":"Santa Claus: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["Santa","Claus:","light","skin","tone"]}, + {"emoji":"🎅🏼","name":"Santa Claus: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["Santa","Claus:","medium-light","skin","tone"]}, + {"emoji":"🎅🏽","name":"Santa Claus: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["Santa","Claus:","medium","skin","tone"]}, + {"emoji":"🎅🏾","name":"Santa Claus: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["Santa","Claus:","medium-dark","skin","tone"]}, + {"emoji":"🎅🏿","name":"Santa Claus: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["Santa","Claus:","dark","skin","tone"]}, + {"emoji":"🤶","name":"Mrs. Claus","category":"Smileys & People","version":"3.0","skinTone":false,"keywords":["Mrs.","Claus","celebration","Christmas","claus","fairy","fantasy","holiday","merry","mother","Mrs","santa","tale","xmas"]}, + {"emoji":"🤶🏻","name":"Mrs. Claus: light skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["Mrs.","Claus:","light","skin","tone"]}, + {"emoji":"🤶🏼","name":"Mrs. Claus: medium-light skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["Mrs.","Claus:","medium-light","skin","tone"]}, + {"emoji":"🤶🏽","name":"Mrs. Claus: medium skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["Mrs.","Claus:","medium","skin","tone"]}, + {"emoji":"🤶🏾","name":"Mrs. Claus: medium-dark skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["Mrs.","Claus:","medium-dark","skin","tone"]}, + {"emoji":"🤶🏿","name":"Mrs. Claus: dark skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["Mrs.","Claus:","dark","skin","tone"]}, + {"emoji":"🧑‍🎄","name":"Mx Claus","category":"Smileys & People","version":"13.0","skinTone":false,"keywords":["Mx","Claus","celebration","Christmas","claus","fairy","fantasy","holiday","merry","santa","tale","xmas"]}, + {"emoji":"🧑🏻‍🎄","name":"Mx Claus: light skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["Mx","Claus:","light","skin","tone"]}, + {"emoji":"🧑🏼‍🎄","name":"Mx Claus: medium-light skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["Mx","Claus:","medium-light","skin","tone"]}, + {"emoji":"🧑🏽‍🎄","name":"Mx Claus: medium skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["Mx","Claus:","medium","skin","tone"]}, + {"emoji":"🧑🏾‍🎄","name":"Mx Claus: medium-dark skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["Mx","Claus:","medium-dark","skin","tone"]}, + {"emoji":"🧑🏿‍🎄","name":"Mx Claus: dark skin tone","category":"Smileys & People","version":"13.0","skinTone":true,"keywords":["Mx","Claus:","dark","skin","tone"]}, + {"emoji":"🦸","name":"superhero","category":"Smileys & People","version":"11.0","skinTone":false,"keywords":["superhero","good","hero","superpower"]}, + {"emoji":"🦸🏻","name":"superhero: light skin tone","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["superhero:","light","skin","tone"]}, + {"emoji":"🦸🏼","name":"superhero: medium-light skin tone","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["superhero:","medium-light","skin","tone"]}, + {"emoji":"🦸🏽","name":"superhero: medium skin tone","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["superhero:","medium","skin","tone"]}, + {"emoji":"🦸🏾","name":"superhero: medium-dark skin tone","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["superhero:","medium-dark","skin","tone"]}, + {"emoji":"🦸🏿","name":"superhero: dark skin tone","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["superhero:","dark","skin","tone"]}, + {"emoji":"🦸‍♂️","name":"man superhero","category":"Smileys & People","version":"11.0","skinTone":false,"keywords":["man","superhero"]}, + {"emoji":"🦸🏻‍♂️","name":"man superhero: light skin tone","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["man","superhero:","light","skin","tone"]}, + {"emoji":"🦸🏼‍♂️","name":"man superhero: medium-light skin tone","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["man","superhero:","medium-light","skin","tone"]}, + {"emoji":"🦸🏽‍♂️","name":"man superhero: medium skin tone","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["man","superhero:","medium","skin","tone"]}, + {"emoji":"🦸🏾‍♂️","name":"man superhero: medium-dark skin tone","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["man","superhero:","medium-dark","skin","tone"]}, + {"emoji":"🦸🏿‍♂️","name":"man superhero: dark skin tone","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["man","superhero:","dark","skin","tone"]}, + {"emoji":"🦸‍♀️","name":"woman superhero","category":"Smileys & People","version":"11.0","skinTone":false,"keywords":["woman","superhero"]}, + {"emoji":"🦸🏻‍♀️","name":"woman superhero: light skin tone","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["woman","superhero:","light","skin","tone"]}, + {"emoji":"🦸🏼‍♀️","name":"woman superhero: medium-light skin tone","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["woman","superhero:","medium-light","skin","tone"]}, + {"emoji":"🦸🏽‍♀️","name":"woman superhero: medium skin tone","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["woman","superhero:","medium","skin","tone"]}, + {"emoji":"🦸🏾‍♀️","name":"woman superhero: medium-dark skin tone","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["woman","superhero:","medium-dark","skin","tone"]}, + {"emoji":"🦸🏿‍♀️","name":"woman superhero: dark skin tone","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["woman","superhero:","dark","skin","tone"]}, + {"emoji":"🦹","name":"supervillain","category":"Smileys & People","version":"11.0","skinTone":false,"keywords":["supervillain","bad","criminal","evil","superpower","villain"]}, + {"emoji":"🦹🏻","name":"supervillain: light skin tone","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["supervillain:","light","skin","tone"]}, + {"emoji":"🦹🏼","name":"supervillain: medium-light skin tone","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["supervillain:","medium-light","skin","tone"]}, + {"emoji":"🦹🏽","name":"supervillain: medium skin tone","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["supervillain:","medium","skin","tone"]}, + {"emoji":"🦹🏾","name":"supervillain: medium-dark skin tone","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["supervillain:","medium-dark","skin","tone"]}, + {"emoji":"🦹🏿","name":"supervillain: dark skin tone","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["supervillain:","dark","skin","tone"]}, + {"emoji":"🦹‍♂️","name":"man supervillain","category":"Smileys & People","version":"11.0","skinTone":false,"keywords":["man","supervillain"]}, + {"emoji":"🦹🏻‍♂️","name":"man supervillain: light skin tone","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["man","supervillain:","light","skin","tone"]}, + {"emoji":"🦹🏼‍♂️","name":"man supervillain: medium-light skin tone","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["man","supervillain:","medium-light","skin","tone"]}, + {"emoji":"🦹🏽‍♂️","name":"man supervillain: medium skin tone","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["man","supervillain:","medium","skin","tone"]}, + {"emoji":"🦹🏾‍♂️","name":"man supervillain: medium-dark skin tone","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["man","supervillain:","medium-dark","skin","tone"]}, + {"emoji":"🦹🏿‍♂️","name":"man supervillain: dark skin tone","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["man","supervillain:","dark","skin","tone"]}, + {"emoji":"🦹‍♀️","name":"woman supervillain","category":"Smileys & People","version":"11.0","skinTone":false,"keywords":["woman","supervillain"]}, + {"emoji":"🦹🏻‍♀️","name":"woman supervillain: light skin tone","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["woman","supervillain:","light","skin","tone"]}, + {"emoji":"🦹🏼‍♀️","name":"woman supervillain: medium-light skin tone","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["woman","supervillain:","medium-light","skin","tone"]}, + {"emoji":"🦹🏽‍♀️","name":"woman supervillain: medium skin tone","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["woman","supervillain:","medium","skin","tone"]}, + {"emoji":"🦹🏾‍♀️","name":"woman supervillain: medium-dark skin tone","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["woman","supervillain:","medium-dark","skin","tone"]}, + {"emoji":"🦹🏿‍♀️","name":"woman supervillain: dark skin tone","category":"Smileys & People","version":"11.0","skinTone":true,"keywords":["woman","supervillain:","dark","skin","tone"]}, + {"emoji":"🧙","name":"mage","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["mage","fantasy","magic","play","sorcerer","sorceress","sorcery","spell","summon","witch","wizard"]}, + {"emoji":"🧙🏻","name":"mage: light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["mage:","light","skin","tone"]}, + {"emoji":"🧙🏼","name":"mage: medium-light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["mage:","medium-light","skin","tone"]}, + {"emoji":"🧙🏽","name":"mage: medium skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["mage:","medium","skin","tone"]}, + {"emoji":"🧙🏾","name":"mage: medium-dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["mage:","medium-dark","skin","tone"]}, + {"emoji":"🧙🏿","name":"mage: dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["mage:","dark","skin","tone"]}, + {"emoji":"🧙‍♂️","name":"man mage","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["man","mage"]}, + {"emoji":"🧙🏻‍♂️","name":"man mage: light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["man","mage:","light","skin","tone"]}, + {"emoji":"🧙🏼‍♂️","name":"man mage: medium-light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["man","mage:","medium-light","skin","tone"]}, + {"emoji":"🧙🏽‍♂️","name":"man mage: medium skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["man","mage:","medium","skin","tone"]}, + {"emoji":"🧙🏾‍♂️","name":"man mage: medium-dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["man","mage:","medium-dark","skin","tone"]}, + {"emoji":"🧙🏿‍♂️","name":"man mage: dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["man","mage:","dark","skin","tone"]}, + {"emoji":"🧙‍♀️","name":"woman mage","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["woman","mage"]}, + {"emoji":"🧙🏻‍♀️","name":"woman mage: light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["woman","mage:","light","skin","tone"]}, + {"emoji":"🧙🏼‍♀️","name":"woman mage: medium-light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["woman","mage:","medium-light","skin","tone"]}, + {"emoji":"🧙🏽‍♀️","name":"woman mage: medium skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["woman","mage:","medium","skin","tone"]}, + {"emoji":"🧙🏾‍♀️","name":"woman mage: medium-dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["woman","mage:","medium-dark","skin","tone"]}, + {"emoji":"🧙🏿‍♀️","name":"woman mage: dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["woman","mage:","dark","skin","tone"]}, + {"emoji":"🧚","name":"fairy","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["fairy","fairytale","fantasy","myth","person","pixie","tale","wings"]}, + {"emoji":"🧚🏻","name":"fairy: light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["fairy:","light","skin","tone"]}, + {"emoji":"🧚🏼","name":"fairy: medium-light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["fairy:","medium-light","skin","tone"]}, + {"emoji":"🧚🏽","name":"fairy: medium skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["fairy:","medium","skin","tone"]}, + {"emoji":"🧚🏾","name":"fairy: medium-dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["fairy:","medium-dark","skin","tone"]}, + {"emoji":"🧚🏿","name":"fairy: dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["fairy:","dark","skin","tone"]}, + {"emoji":"🧚‍♂️","name":"man fairy","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["man","fairy"]}, + {"emoji":"🧚🏻‍♂️","name":"man fairy: light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["man","fairy:","light","skin","tone"]}, + {"emoji":"🧚🏼‍♂️","name":"man fairy: medium-light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["man","fairy:","medium-light","skin","tone"]}, + {"emoji":"🧚🏽‍♂️","name":"man fairy: medium skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["man","fairy:","medium","skin","tone"]}, + {"emoji":"🧚🏾‍♂️","name":"man fairy: medium-dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["man","fairy:","medium-dark","skin","tone"]}, + {"emoji":"🧚🏿‍♂️","name":"man fairy: dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["man","fairy:","dark","skin","tone"]}, + {"emoji":"🧚‍♀️","name":"woman fairy","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["woman","fairy"]}, + {"emoji":"🧚🏻‍♀️","name":"woman fairy: light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["woman","fairy:","light","skin","tone"]}, + {"emoji":"🧚🏼‍♀️","name":"woman fairy: medium-light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["woman","fairy:","medium-light","skin","tone"]}, + {"emoji":"🧚🏽‍♀️","name":"woman fairy: medium skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["woman","fairy:","medium","skin","tone"]}, + {"emoji":"🧚🏾‍♀️","name":"woman fairy: medium-dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["woman","fairy:","medium-dark","skin","tone"]}, + {"emoji":"🧚🏿‍♀️","name":"woman fairy: dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["woman","fairy:","dark","skin","tone"]}, + {"emoji":"🧛","name":"vampire","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["vampire","blood","Dracula","fangs","halloween","scary","supernatural","teeth","undead"]}, + {"emoji":"🧛🏻","name":"vampire: light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["vampire:","light","skin","tone"]}, + {"emoji":"🧛🏼","name":"vampire: medium-light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["vampire:","medium-light","skin","tone"]}, + {"emoji":"🧛🏽","name":"vampire: medium skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["vampire:","medium","skin","tone"]}, + {"emoji":"🧛🏾","name":"vampire: medium-dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["vampire:","medium-dark","skin","tone"]}, + {"emoji":"🧛🏿","name":"vampire: dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["vampire:","dark","skin","tone"]}, + {"emoji":"🧛‍♂️","name":"man vampire","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["man","vampire"]}, + {"emoji":"🧛🏻‍♂️","name":"man vampire: light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["man","vampire:","light","skin","tone"]}, + {"emoji":"🧛🏼‍♂️","name":"man vampire: medium-light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["man","vampire:","medium-light","skin","tone"]}, + {"emoji":"🧛🏽‍♂️","name":"man vampire: medium skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["man","vampire:","medium","skin","tone"]}, + {"emoji":"🧛🏾‍♂️","name":"man vampire: medium-dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["man","vampire:","medium-dark","skin","tone"]}, + {"emoji":"🧛🏿‍♂️","name":"man vampire: dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["man","vampire:","dark","skin","tone"]}, + {"emoji":"🧛‍♀️","name":"woman vampire","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["woman","vampire"]}, + {"emoji":"🧛🏻‍♀️","name":"woman vampire: light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["woman","vampire:","light","skin","tone"]}, + {"emoji":"🧛🏼‍♀️","name":"woman vampire: medium-light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["woman","vampire:","medium-light","skin","tone"]}, + {"emoji":"🧛🏽‍♀️","name":"woman vampire: medium skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["woman","vampire:","medium","skin","tone"]}, + {"emoji":"🧛🏾‍♀️","name":"woman vampire: medium-dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["woman","vampire:","medium-dark","skin","tone"]}, + {"emoji":"🧛🏿‍♀️","name":"woman vampire: dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["woman","vampire:","dark","skin","tone"]}, + {"emoji":"🧜","name":"merperson","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["merperson","creature","fairytale","folklore","ocean","sea","siren","trident"]}, + {"emoji":"🧜🏻","name":"merperson: light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["merperson:","light","skin","tone"]}, + {"emoji":"🧜🏼","name":"merperson: medium-light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["merperson:","medium-light","skin","tone"]}, + {"emoji":"🧜🏽","name":"merperson: medium skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["merperson:","medium","skin","tone"]}, + {"emoji":"🧜🏾","name":"merperson: medium-dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["merperson:","medium-dark","skin","tone"]}, + {"emoji":"🧜🏿","name":"merperson: dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["merperson:","dark","skin","tone"]}, + {"emoji":"🧜‍♂️","name":"merman","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["merman"]}, + {"emoji":"🧜🏻‍♂️","name":"merman: light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["merman:","light","skin","tone"]}, + {"emoji":"🧜🏼‍♂️","name":"merman: medium-light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["merman:","medium-light","skin","tone"]}, + {"emoji":"🧜🏽‍♂️","name":"merman: medium skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["merman:","medium","skin","tone"]}, + {"emoji":"🧜🏾‍♂️","name":"merman: medium-dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["merman:","medium-dark","skin","tone"]}, + {"emoji":"🧜🏿‍♂️","name":"merman: dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["merman:","dark","skin","tone"]}, + {"emoji":"🧜‍♀️","name":"mermaid","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["mermaid"]}, + {"emoji":"🧜🏻‍♀️","name":"mermaid: light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["mermaid:","light","skin","tone"]}, + {"emoji":"🧜🏼‍♀️","name":"mermaid: medium-light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["mermaid:","medium-light","skin","tone"]}, + {"emoji":"🧜🏽‍♀️","name":"mermaid: medium skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["mermaid:","medium","skin","tone"]}, + {"emoji":"🧜🏾‍♀️","name":"mermaid: medium-dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["mermaid:","medium-dark","skin","tone"]}, + {"emoji":"🧜🏿‍♀️","name":"mermaid: dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["mermaid:","dark","skin","tone"]}, + {"emoji":"🧝","name":"elf","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["elf","elves","enchantment","fantasy","folklore","magic","magical","myth"]}, + {"emoji":"🧝🏻","name":"elf: light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["elf:","light","skin","tone"]}, + {"emoji":"🧝🏼","name":"elf: medium-light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["elf:","medium-light","skin","tone"]}, + {"emoji":"🧝🏽","name":"elf: medium skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["elf:","medium","skin","tone"]}, + {"emoji":"🧝🏾","name":"elf: medium-dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["elf:","medium-dark","skin","tone"]}, + {"emoji":"🧝🏿","name":"elf: dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["elf:","dark","skin","tone"]}, + {"emoji":"🧝‍♂️","name":"man elf","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["man","elf"]}, + {"emoji":"🧝🏻‍♂️","name":"man elf: light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["man","elf:","light","skin","tone"]}, + {"emoji":"🧝🏼‍♂️","name":"man elf: medium-light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["man","elf:","medium-light","skin","tone"]}, + {"emoji":"🧝🏽‍♂️","name":"man elf: medium skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["man","elf:","medium","skin","tone"]}, + {"emoji":"🧝🏾‍♂️","name":"man elf: medium-dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["man","elf:","medium-dark","skin","tone"]}, + {"emoji":"🧝🏿‍♂️","name":"man elf: dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["man","elf:","dark","skin","tone"]}, + {"emoji":"🧝‍♀️","name":"woman elf","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["woman","elf"]}, + {"emoji":"🧝🏻‍♀️","name":"woman elf: light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["woman","elf:","light","skin","tone"]}, + {"emoji":"🧝🏼‍♀️","name":"woman elf: medium-light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["woman","elf:","medium-light","skin","tone"]}, + {"emoji":"🧝🏽‍♀️","name":"woman elf: medium skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["woman","elf:","medium","skin","tone"]}, + {"emoji":"🧝🏾‍♀️","name":"woman elf: medium-dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["woman","elf:","medium-dark","skin","tone"]}, + {"emoji":"🧝🏿‍♀️","name":"woman elf: dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["woman","elf:","dark","skin","tone"]}, + {"emoji":"🧞","name":"genie","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["genie","djinn","fantasy","jinn","lamp","myth","rub","wishes"]}, + {"emoji":"🧞‍♂️","name":"man genie","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["man","genie"]}, + {"emoji":"🧞‍♀️","name":"woman genie","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["woman","genie"]}, + {"emoji":"🧟","name":"zombie","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["zombie","apocalypse","dead","halloween","horror","scary","undead","walking"]}, + {"emoji":"🧟‍♂️","name":"man zombie","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["man","zombie"]}, + {"emoji":"🧟‍♀️","name":"woman zombie","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["woman","zombie"]}, + {"emoji":"🧌","name":"troll","category":"Smileys & People","version":"14.0","skinTone":false,"keywords":["troll","fairy","fantasy","monster","tale","trolling"]}, + {"emoji":"🫈","name":"hairy creature","category":"Smileys & People","version":"17.0","skinTone":false,"keywords":["hairy","creature","bigfoot","cryptid","forest","giant","sasquatch","woodwose","yeti"]}, + {"emoji":"💆","name":"person getting massage","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["person","getting","massage","face","headache","relax","relaxing","salon","soothe","spa","tension","therapy","treatment"]}, + {"emoji":"💆🏻","name":"person getting massage: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","getting","massage:","light","skin","tone"]}, + {"emoji":"💆🏼","name":"person getting massage: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","getting","massage:","medium-light","skin","tone"]}, + {"emoji":"💆🏽","name":"person getting massage: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","getting","massage:","medium","skin","tone"]}, + {"emoji":"💆🏾","name":"person getting massage: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","getting","massage:","medium-dark","skin","tone"]}, + {"emoji":"💆🏿","name":"person getting massage: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","getting","massage:","dark","skin","tone"]}, + {"emoji":"💆‍♂️","name":"man getting massage","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man","getting","massage"]}, + {"emoji":"💆🏻‍♂️","name":"man getting massage: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","getting","massage:","light","skin","tone"]}, + {"emoji":"💆🏼‍♂️","name":"man getting massage: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","getting","massage:","medium-light","skin","tone"]}, + {"emoji":"💆🏽‍♂️","name":"man getting massage: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","getting","massage:","medium","skin","tone"]}, + {"emoji":"💆🏾‍♂️","name":"man getting massage: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","getting","massage:","medium-dark","skin","tone"]}, + {"emoji":"💆🏿‍♂️","name":"man getting massage: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","getting","massage:","dark","skin","tone"]}, + {"emoji":"💆‍♀️","name":"woman getting massage","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman","getting","massage"]}, + {"emoji":"💆🏻‍♀️","name":"woman getting massage: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","getting","massage:","light","skin","tone"]}, + {"emoji":"💆🏼‍♀️","name":"woman getting massage: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","getting","massage:","medium-light","skin","tone"]}, + {"emoji":"💆🏽‍♀️","name":"woman getting massage: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","getting","massage:","medium","skin","tone"]}, + {"emoji":"💆🏾‍♀️","name":"woman getting massage: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","getting","massage:","medium-dark","skin","tone"]}, + {"emoji":"💆🏿‍♀️","name":"woman getting massage: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","getting","massage:","dark","skin","tone"]}, + {"emoji":"💇","name":"person getting haircut","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["person","getting","haircut","barber","beauty","chop","cosmetology","cut","groom","hair","parlor","shears","style"]}, + {"emoji":"💇🏻","name":"person getting haircut: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","getting","haircut:","light","skin","tone"]}, + {"emoji":"💇🏼","name":"person getting haircut: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","getting","haircut:","medium-light","skin","tone"]}, + {"emoji":"💇🏽","name":"person getting haircut: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","getting","haircut:","medium","skin","tone"]}, + {"emoji":"💇🏾","name":"person getting haircut: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","getting","haircut:","medium-dark","skin","tone"]}, + {"emoji":"💇🏿","name":"person getting haircut: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","getting","haircut:","dark","skin","tone"]}, + {"emoji":"💇‍♂️","name":"man getting haircut","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man","getting","haircut"]}, + {"emoji":"💇🏻‍♂️","name":"man getting haircut: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","getting","haircut:","light","skin","tone"]}, + {"emoji":"💇🏼‍♂️","name":"man getting haircut: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","getting","haircut:","medium-light","skin","tone"]}, + {"emoji":"💇🏽‍♂️","name":"man getting haircut: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","getting","haircut:","medium","skin","tone"]}, + {"emoji":"💇🏾‍♂️","name":"man getting haircut: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","getting","haircut:","medium-dark","skin","tone"]}, + {"emoji":"💇🏿‍♂️","name":"man getting haircut: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","getting","haircut:","dark","skin","tone"]}, + {"emoji":"💇‍♀️","name":"woman getting haircut","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman","getting","haircut"]}, + {"emoji":"💇🏻‍♀️","name":"woman getting haircut: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","getting","haircut:","light","skin","tone"]}, + {"emoji":"💇🏼‍♀️","name":"woman getting haircut: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","getting","haircut:","medium-light","skin","tone"]}, + {"emoji":"💇🏽‍♀️","name":"woman getting haircut: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","getting","haircut:","medium","skin","tone"]}, + {"emoji":"💇🏾‍♀️","name":"woman getting haircut: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","getting","haircut:","medium-dark","skin","tone"]}, + {"emoji":"💇🏿‍♀️","name":"woman getting haircut: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","getting","haircut:","dark","skin","tone"]}, + {"emoji":"🚶","name":"person walking","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["person","walking","amble","gait","hike","man","pace","pedestrian","stride","stroll","walk"]}, + {"emoji":"🚶🏻","name":"person walking: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","walking:","light","skin","tone"]}, + {"emoji":"🚶🏼","name":"person walking: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","walking:","medium-light","skin","tone"]}, + {"emoji":"🚶🏽","name":"person walking: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","walking:","medium","skin","tone"]}, + {"emoji":"🚶🏾","name":"person walking: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","walking:","medium-dark","skin","tone"]}, + {"emoji":"🚶🏿","name":"person walking: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","walking:","dark","skin","tone"]}, + {"emoji":"🚶‍♂️","name":"man walking","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man","walking"]}, + {"emoji":"🚶🏻‍♂️","name":"man walking: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","walking:","light","skin","tone"]}, + {"emoji":"🚶🏼‍♂️","name":"man walking: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","walking:","medium-light","skin","tone"]}, + {"emoji":"🚶🏽‍♂️","name":"man walking: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","walking:","medium","skin","tone"]}, + {"emoji":"🚶🏾‍♂️","name":"man walking: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","walking:","medium-dark","skin","tone"]}, + {"emoji":"🚶🏿‍♂️","name":"man walking: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","walking:","dark","skin","tone"]}, + {"emoji":"🚶‍♀️","name":"woman walking","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman","walking"]}, + {"emoji":"🚶🏻‍♀️","name":"woman walking: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","walking:","light","skin","tone"]}, + {"emoji":"🚶🏼‍♀️","name":"woman walking: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","walking:","medium-light","skin","tone"]}, + {"emoji":"🚶🏽‍♀️","name":"woman walking: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","walking:","medium","skin","tone"]}, + {"emoji":"🚶🏾‍♀️","name":"woman walking: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","walking:","medium-dark","skin","tone"]}, + {"emoji":"🚶🏿‍♀️","name":"woman walking: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","walking:","dark","skin","tone"]}, + {"emoji":"🚶‍➡️","name":"person walking facing right","category":"Smileys & People","version":"15.1","skinTone":false,"keywords":["person","walking","facing","right"]}, + {"emoji":"🚶🏻‍➡️","name":"person walking facing right: light skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["person","walking","facing","right:","light","skin","tone"]}, + {"emoji":"🚶🏼‍➡️","name":"person walking facing right: medium-light skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["person","walking","facing","right:","medium-light","skin","tone"]}, + {"emoji":"🚶🏽‍➡️","name":"person walking facing right: medium skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["person","walking","facing","right:","medium","skin","tone"]}, + {"emoji":"🚶🏾‍➡️","name":"person walking facing right: medium-dark skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["person","walking","facing","right:","medium-dark","skin","tone"]}, + {"emoji":"🚶🏿‍➡️","name":"person walking facing right: dark skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["person","walking","facing","right:","dark","skin","tone"]}, + {"emoji":"🚶‍♀️‍➡️","name":"woman walking facing right","category":"Smileys & People","version":"15.1","skinTone":false,"keywords":["woman","walking","facing","right"]}, + {"emoji":"🚶🏻‍♀️‍➡️","name":"woman walking facing right: light skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["woman","walking","facing","right:","light","skin","tone"]}, + {"emoji":"🚶🏼‍♀️‍➡️","name":"woman walking facing right: medium-light skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["woman","walking","facing","right:","medium-light","skin","tone"]}, + {"emoji":"🚶🏽‍♀️‍➡️","name":"woman walking facing right: medium skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["woman","walking","facing","right:","medium","skin","tone"]}, + {"emoji":"🚶🏾‍♀️‍➡️","name":"woman walking facing right: medium-dark skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["woman","walking","facing","right:","medium-dark","skin","tone"]}, + {"emoji":"🚶🏿‍♀️‍➡️","name":"woman walking facing right: dark skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["woman","walking","facing","right:","dark","skin","tone"]}, + {"emoji":"🚶‍♂️‍➡️","name":"man walking facing right","category":"Smileys & People","version":"15.1","skinTone":false,"keywords":["man","walking","facing","right"]}, + {"emoji":"🚶🏻‍♂️‍➡️","name":"man walking facing right: light skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["man","walking","facing","right:","light","skin","tone"]}, + {"emoji":"🚶🏼‍♂️‍➡️","name":"man walking facing right: medium-light skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["man","walking","facing","right:","medium-light","skin","tone"]}, + {"emoji":"🚶🏽‍♂️‍➡️","name":"man walking facing right: medium skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["man","walking","facing","right:","medium","skin","tone"]}, + {"emoji":"🚶🏾‍♂️‍➡️","name":"man walking facing right: medium-dark skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["man","walking","facing","right:","medium-dark","skin","tone"]}, + {"emoji":"🚶🏿‍♂️‍➡️","name":"man walking facing right: dark skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["man","walking","facing","right:","dark","skin","tone"]}, + {"emoji":"🧍","name":"person standing","category":"Smileys & People","version":"12.0","skinTone":false,"keywords":["person","standing","stand"]}, + {"emoji":"🧍🏻","name":"person standing: light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["person","standing:","light","skin","tone"]}, + {"emoji":"🧍🏼","name":"person standing: medium-light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["person","standing:","medium-light","skin","tone"]}, + {"emoji":"🧍🏽","name":"person standing: medium skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["person","standing:","medium","skin","tone"]}, + {"emoji":"🧍🏾","name":"person standing: medium-dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["person","standing:","medium-dark","skin","tone"]}, + {"emoji":"🧍🏿","name":"person standing: dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["person","standing:","dark","skin","tone"]}, + {"emoji":"🧍‍♂️","name":"man standing","category":"Smileys & People","version":"12.0","skinTone":false,"keywords":["man","standing"]}, + {"emoji":"🧍🏻‍♂️","name":"man standing: light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["man","standing:","light","skin","tone"]}, + {"emoji":"🧍🏼‍♂️","name":"man standing: medium-light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["man","standing:","medium-light","skin","tone"]}, + {"emoji":"🧍🏽‍♂️","name":"man standing: medium skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["man","standing:","medium","skin","tone"]}, + {"emoji":"🧍🏾‍♂️","name":"man standing: medium-dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["man","standing:","medium-dark","skin","tone"]}, + {"emoji":"🧍🏿‍♂️","name":"man standing: dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["man","standing:","dark","skin","tone"]}, + {"emoji":"🧍‍♀️","name":"woman standing","category":"Smileys & People","version":"12.0","skinTone":false,"keywords":["woman","standing"]}, + {"emoji":"🧍🏻‍♀️","name":"woman standing: light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","standing:","light","skin","tone"]}, + {"emoji":"🧍🏼‍♀️","name":"woman standing: medium-light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","standing:","medium-light","skin","tone"]}, + {"emoji":"🧍🏽‍♀️","name":"woman standing: medium skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","standing:","medium","skin","tone"]}, + {"emoji":"🧍🏾‍♀️","name":"woman standing: medium-dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","standing:","medium-dark","skin","tone"]}, + {"emoji":"🧍🏿‍♀️","name":"woman standing: dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","standing:","dark","skin","tone"]}, + {"emoji":"🧎","name":"person kneeling","category":"Smileys & People","version":"12.0","skinTone":false,"keywords":["person","kneeling","kneel","knees"]}, + {"emoji":"🧎🏻","name":"person kneeling: light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["person","kneeling:","light","skin","tone"]}, + {"emoji":"🧎🏼","name":"person kneeling: medium-light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["person","kneeling:","medium-light","skin","tone"]}, + {"emoji":"🧎🏽","name":"person kneeling: medium skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["person","kneeling:","medium","skin","tone"]}, + {"emoji":"🧎🏾","name":"person kneeling: medium-dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["person","kneeling:","medium-dark","skin","tone"]}, + {"emoji":"🧎🏿","name":"person kneeling: dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["person","kneeling:","dark","skin","tone"]}, + {"emoji":"🧎‍♂️","name":"man kneeling","category":"Smileys & People","version":"12.0","skinTone":false,"keywords":["man","kneeling"]}, + {"emoji":"🧎🏻‍♂️","name":"man kneeling: light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["man","kneeling:","light","skin","tone"]}, + {"emoji":"🧎🏼‍♂️","name":"man kneeling: medium-light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["man","kneeling:","medium-light","skin","tone"]}, + {"emoji":"🧎🏽‍♂️","name":"man kneeling: medium skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["man","kneeling:","medium","skin","tone"]}, + {"emoji":"🧎🏾‍♂️","name":"man kneeling: medium-dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["man","kneeling:","medium-dark","skin","tone"]}, + {"emoji":"🧎🏿‍♂️","name":"man kneeling: dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["man","kneeling:","dark","skin","tone"]}, + {"emoji":"🧎‍♀️","name":"woman kneeling","category":"Smileys & People","version":"12.0","skinTone":false,"keywords":["woman","kneeling"]}, + {"emoji":"🧎🏻‍♀️","name":"woman kneeling: light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","kneeling:","light","skin","tone"]}, + {"emoji":"🧎🏼‍♀️","name":"woman kneeling: medium-light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","kneeling:","medium-light","skin","tone"]}, + {"emoji":"🧎🏽‍♀️","name":"woman kneeling: medium skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","kneeling:","medium","skin","tone"]}, + {"emoji":"🧎🏾‍♀️","name":"woman kneeling: medium-dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","kneeling:","medium-dark","skin","tone"]}, + {"emoji":"🧎🏿‍♀️","name":"woman kneeling: dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","kneeling:","dark","skin","tone"]}, + {"emoji":"🧎‍➡️","name":"person kneeling facing right","category":"Smileys & People","version":"15.1","skinTone":false,"keywords":["person","kneeling","facing","right"]}, + {"emoji":"🧎🏻‍➡️","name":"person kneeling facing right: light skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["person","kneeling","facing","right:","light","skin","tone"]}, + {"emoji":"🧎🏼‍➡️","name":"person kneeling facing right: medium-light skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["person","kneeling","facing","right:","medium-light","skin","tone"]}, + {"emoji":"🧎🏽‍➡️","name":"person kneeling facing right: medium skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["person","kneeling","facing","right:","medium","skin","tone"]}, + {"emoji":"🧎🏾‍➡️","name":"person kneeling facing right: medium-dark skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["person","kneeling","facing","right:","medium-dark","skin","tone"]}, + {"emoji":"🧎🏿‍➡️","name":"person kneeling facing right: dark skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["person","kneeling","facing","right:","dark","skin","tone"]}, + {"emoji":"🧎‍♀️‍➡️","name":"woman kneeling facing right","category":"Smileys & People","version":"15.1","skinTone":false,"keywords":["woman","kneeling","facing","right"]}, + {"emoji":"🧎🏻‍♀️‍➡️","name":"woman kneeling facing right: light skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["woman","kneeling","facing","right:","light","skin","tone"]}, + {"emoji":"🧎🏼‍♀️‍➡️","name":"woman kneeling facing right: medium-light skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["woman","kneeling","facing","right:","medium-light","skin","tone"]}, + {"emoji":"🧎🏽‍♀️‍➡️","name":"woman kneeling facing right: medium skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["woman","kneeling","facing","right:","medium","skin","tone"]}, + {"emoji":"🧎🏾‍♀️‍➡️","name":"woman kneeling facing right: medium-dark skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["woman","kneeling","facing","right:","medium-dark","skin","tone"]}, + {"emoji":"🧎🏿‍♀️‍➡️","name":"woman kneeling facing right: dark skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["woman","kneeling","facing","right:","dark","skin","tone"]}, + {"emoji":"🧎‍♂️‍➡️","name":"man kneeling facing right","category":"Smileys & People","version":"15.1","skinTone":false,"keywords":["man","kneeling","facing","right"]}, + {"emoji":"🧎🏻‍♂️‍➡️","name":"man kneeling facing right: light skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["man","kneeling","facing","right:","light","skin","tone"]}, + {"emoji":"🧎🏼‍♂️‍➡️","name":"man kneeling facing right: medium-light skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["man","kneeling","facing","right:","medium-light","skin","tone"]}, + {"emoji":"🧎🏽‍♂️‍➡️","name":"man kneeling facing right: medium skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["man","kneeling","facing","right:","medium","skin","tone"]}, + {"emoji":"🧎🏾‍♂️‍➡️","name":"man kneeling facing right: medium-dark skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["man","kneeling","facing","right:","medium-dark","skin","tone"]}, + {"emoji":"🧎🏿‍♂️‍➡️","name":"man kneeling facing right: dark skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["man","kneeling","facing","right:","dark","skin","tone"]}, + {"emoji":"🧑‍🦯","name":"person with white cane","category":"Smileys & People","version":"12.1","skinTone":false,"keywords":["person","with","white","cane","accessibility","blind","probing"]}, + {"emoji":"🧑🏻‍🦯","name":"person with white cane: light skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["person","with","white","cane:","light","skin","tone"]}, + {"emoji":"🧑🏼‍🦯","name":"person with white cane: medium-light skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["person","with","white","cane:","medium-light","skin","tone"]}, + {"emoji":"🧑🏽‍🦯","name":"person with white cane: medium skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["person","with","white","cane:","medium","skin","tone"]}, + {"emoji":"🧑🏾‍🦯","name":"person with white cane: medium-dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["person","with","white","cane:","medium-dark","skin","tone"]}, + {"emoji":"🧑🏿‍🦯","name":"person with white cane: dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["person","with","white","cane:","dark","skin","tone"]}, + {"emoji":"🧑‍🦯‍➡️","name":"person with white cane facing right","category":"Smileys & People","version":"15.1","skinTone":false,"keywords":["person","with","white","cane","facing","right"]}, + {"emoji":"🧑🏻‍🦯‍➡️","name":"person with white cane facing right: light skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["person","with","white","cane","facing","right:","light","skin","tone"]}, + {"emoji":"🧑🏼‍🦯‍➡️","name":"person with white cane facing right: medium-light skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["person","with","white","cane","facing","right:","medium-light","skin","tone"]}, + {"emoji":"🧑🏽‍🦯‍➡️","name":"person with white cane facing right: medium skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["person","with","white","cane","facing","right:","medium","skin","tone"]}, + {"emoji":"🧑🏾‍🦯‍➡️","name":"person with white cane facing right: medium-dark skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["person","with","white","cane","facing","right:","medium-dark","skin","tone"]}, + {"emoji":"🧑🏿‍🦯‍➡️","name":"person with white cane facing right: dark skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["person","with","white","cane","facing","right:","dark","skin","tone"]}, + {"emoji":"👨‍🦯","name":"man with white cane","category":"Smileys & People","version":"12.0","skinTone":false,"keywords":["man","with","white","cane","accessibility","blind","probing"]}, + {"emoji":"👨🏻‍🦯","name":"man with white cane: light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["man","with","white","cane:","light","skin","tone"]}, + {"emoji":"👨🏼‍🦯","name":"man with white cane: medium-light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["man","with","white","cane:","medium-light","skin","tone"]}, + {"emoji":"👨🏽‍🦯","name":"man with white cane: medium skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["man","with","white","cane:","medium","skin","tone"]}, + {"emoji":"👨🏾‍🦯","name":"man with white cane: medium-dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["man","with","white","cane:","medium-dark","skin","tone"]}, + {"emoji":"👨🏿‍🦯","name":"man with white cane: dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["man","with","white","cane:","dark","skin","tone"]}, + {"emoji":"👨‍🦯‍➡️","name":"man with white cane facing right","category":"Smileys & People","version":"15.1","skinTone":false,"keywords":["man","with","white","cane","facing","right"]}, + {"emoji":"👨🏻‍🦯‍➡️","name":"man with white cane facing right: light skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["man","with","white","cane","facing","right:","light","skin","tone"]}, + {"emoji":"👨🏼‍🦯‍➡️","name":"man with white cane facing right: medium-light skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["man","with","white","cane","facing","right:","medium-light","skin","tone"]}, + {"emoji":"👨🏽‍🦯‍➡️","name":"man with white cane facing right: medium skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["man","with","white","cane","facing","right:","medium","skin","tone"]}, + {"emoji":"👨🏾‍🦯‍➡️","name":"man with white cane facing right: medium-dark skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["man","with","white","cane","facing","right:","medium-dark","skin","tone"]}, + {"emoji":"👨🏿‍🦯‍➡️","name":"man with white cane facing right: dark skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["man","with","white","cane","facing","right:","dark","skin","tone"]}, + {"emoji":"👩‍🦯","name":"woman with white cane","category":"Smileys & People","version":"12.0","skinTone":false,"keywords":["woman","with","white","cane","accessibility","blind","probing"]}, + {"emoji":"👩🏻‍🦯","name":"woman with white cane: light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","with","white","cane:","light","skin","tone"]}, + {"emoji":"👩🏼‍🦯","name":"woman with white cane: medium-light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","with","white","cane:","medium-light","skin","tone"]}, + {"emoji":"👩🏽‍🦯","name":"woman with white cane: medium skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","with","white","cane:","medium","skin","tone"]}, + {"emoji":"👩🏾‍🦯","name":"woman with white cane: medium-dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","with","white","cane:","medium-dark","skin","tone"]}, + {"emoji":"👩🏿‍🦯","name":"woman with white cane: dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","with","white","cane:","dark","skin","tone"]}, + {"emoji":"👩‍🦯‍➡️","name":"woman with white cane facing right","category":"Smileys & People","version":"15.1","skinTone":false,"keywords":["woman","with","white","cane","facing","right"]}, + {"emoji":"👩🏻‍🦯‍➡️","name":"woman with white cane facing right: light skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["woman","with","white","cane","facing","right:","light","skin","tone"]}, + {"emoji":"👩🏼‍🦯‍➡️","name":"woman with white cane facing right: medium-light skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["woman","with","white","cane","facing","right:","medium-light","skin","tone"]}, + {"emoji":"👩🏽‍🦯‍➡️","name":"woman with white cane facing right: medium skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["woman","with","white","cane","facing","right:","medium","skin","tone"]}, + {"emoji":"👩🏾‍🦯‍➡️","name":"woman with white cane facing right: medium-dark skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["woman","with","white","cane","facing","right:","medium-dark","skin","tone"]}, + {"emoji":"👩🏿‍🦯‍➡️","name":"woman with white cane facing right: dark skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["woman","with","white","cane","facing","right:","dark","skin","tone"]}, + {"emoji":"🧑‍🦼","name":"person in motorized wheelchair","category":"Smileys & People","version":"12.1","skinTone":false,"keywords":["person","in","motorized","wheelchair","accessibility"]}, + {"emoji":"🧑🏻‍🦼","name":"person in motorized wheelchair: light skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["person","in","motorized","wheelchair:","light","skin","tone"]}, + {"emoji":"🧑🏼‍🦼","name":"person in motorized wheelchair: medium-light skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["person","in","motorized","wheelchair:","medium-light","skin","tone"]}, + {"emoji":"🧑🏽‍🦼","name":"person in motorized wheelchair: medium skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["person","in","motorized","wheelchair:","medium","skin","tone"]}, + {"emoji":"🧑🏾‍🦼","name":"person in motorized wheelchair: medium-dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["person","in","motorized","wheelchair:","medium-dark","skin","tone"]}, + {"emoji":"🧑🏿‍🦼","name":"person in motorized wheelchair: dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["person","in","motorized","wheelchair:","dark","skin","tone"]}, + {"emoji":"🧑‍🦼‍➡️","name":"person in motorized wheelchair facing right","category":"Smileys & People","version":"15.1","skinTone":false,"keywords":["person","in","motorized","wheelchair","facing","right"]}, + {"emoji":"🧑🏻‍🦼‍➡️","name":"person in motorized wheelchair facing right: light skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["person","in","motorized","wheelchair","facing","right:","light","skin","tone"]}, + {"emoji":"🧑🏼‍🦼‍➡️","name":"person in motorized wheelchair facing right: medium-light skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["person","in","motorized","wheelchair","facing","right:","medium-light","skin","tone"]}, + {"emoji":"🧑🏽‍🦼‍➡️","name":"person in motorized wheelchair facing right: medium skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["person","in","motorized","wheelchair","facing","right:","medium","skin","tone"]}, + {"emoji":"🧑🏾‍🦼‍➡️","name":"person in motorized wheelchair facing right: medium-dark skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["person","in","motorized","wheelchair","facing","right:","medium-dark","skin","tone"]}, + {"emoji":"🧑🏿‍🦼‍➡️","name":"person in motorized wheelchair facing right: dark skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["person","in","motorized","wheelchair","facing","right:","dark","skin","tone"]}, + {"emoji":"👨‍🦼","name":"man in motorized wheelchair","category":"Smileys & People","version":"12.0","skinTone":false,"keywords":["man","in","motorized","wheelchair","accessibility"]}, + {"emoji":"👨🏻‍🦼","name":"man in motorized wheelchair: light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["man","in","motorized","wheelchair:","light","skin","tone"]}, + {"emoji":"👨🏼‍🦼","name":"man in motorized wheelchair: medium-light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["man","in","motorized","wheelchair:","medium-light","skin","tone"]}, + {"emoji":"👨🏽‍🦼","name":"man in motorized wheelchair: medium skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["man","in","motorized","wheelchair:","medium","skin","tone"]}, + {"emoji":"👨🏾‍🦼","name":"man in motorized wheelchair: medium-dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["man","in","motorized","wheelchair:","medium-dark","skin","tone"]}, + {"emoji":"👨🏿‍🦼","name":"man in motorized wheelchair: dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["man","in","motorized","wheelchair:","dark","skin","tone"]}, + {"emoji":"👨‍🦼‍➡️","name":"man in motorized wheelchair facing right","category":"Smileys & People","version":"15.1","skinTone":false,"keywords":["man","in","motorized","wheelchair","facing","right"]}, + {"emoji":"👨🏻‍🦼‍➡️","name":"man in motorized wheelchair facing right: light skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["man","in","motorized","wheelchair","facing","right:","light","skin","tone"]}, + {"emoji":"👨🏼‍🦼‍➡️","name":"man in motorized wheelchair facing right: medium-light skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["man","in","motorized","wheelchair","facing","right:","medium-light","skin","tone"]}, + {"emoji":"👨🏽‍🦼‍➡️","name":"man in motorized wheelchair facing right: medium skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["man","in","motorized","wheelchair","facing","right:","medium","skin","tone"]}, + {"emoji":"👨🏾‍🦼‍➡️","name":"man in motorized wheelchair facing right: medium-dark skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["man","in","motorized","wheelchair","facing","right:","medium-dark","skin","tone"]}, + {"emoji":"👨🏿‍🦼‍➡️","name":"man in motorized wheelchair facing right: dark skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["man","in","motorized","wheelchair","facing","right:","dark","skin","tone"]}, + {"emoji":"👩‍🦼","name":"woman in motorized wheelchair","category":"Smileys & People","version":"12.0","skinTone":false,"keywords":["woman","in","motorized","wheelchair","accessibility"]}, + {"emoji":"👩🏻‍🦼","name":"woman in motorized wheelchair: light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","in","motorized","wheelchair:","light","skin","tone"]}, + {"emoji":"👩🏼‍🦼","name":"woman in motorized wheelchair: medium-light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","in","motorized","wheelchair:","medium-light","skin","tone"]}, + {"emoji":"👩🏽‍🦼","name":"woman in motorized wheelchair: medium skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","in","motorized","wheelchair:","medium","skin","tone"]}, + {"emoji":"👩🏾‍🦼","name":"woman in motorized wheelchair: medium-dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","in","motorized","wheelchair:","medium-dark","skin","tone"]}, + {"emoji":"👩🏿‍🦼","name":"woman in motorized wheelchair: dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","in","motorized","wheelchair:","dark","skin","tone"]}, + {"emoji":"👩‍🦼‍➡️","name":"woman in motorized wheelchair facing right","category":"Smileys & People","version":"15.1","skinTone":false,"keywords":["woman","in","motorized","wheelchair","facing","right"]}, + {"emoji":"👩🏻‍🦼‍➡️","name":"woman in motorized wheelchair facing right: light skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["woman","in","motorized","wheelchair","facing","right:","light","skin","tone"]}, + {"emoji":"👩🏼‍🦼‍➡️","name":"woman in motorized wheelchair facing right: medium-light skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["woman","in","motorized","wheelchair","facing","right:","medium-light","skin","tone"]}, + {"emoji":"👩🏽‍🦼‍➡️","name":"woman in motorized wheelchair facing right: medium skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["woman","in","motorized","wheelchair","facing","right:","medium","skin","tone"]}, + {"emoji":"👩🏾‍🦼‍➡️","name":"woman in motorized wheelchair facing right: medium-dark skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["woman","in","motorized","wheelchair","facing","right:","medium-dark","skin","tone"]}, + {"emoji":"👩🏿‍🦼‍➡️","name":"woman in motorized wheelchair facing right: dark skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["woman","in","motorized","wheelchair","facing","right:","dark","skin","tone"]}, + {"emoji":"🧑‍🦽","name":"person in manual wheelchair","category":"Smileys & People","version":"12.1","skinTone":false,"keywords":["person","in","manual","wheelchair","accessibility"]}, + {"emoji":"🧑🏻‍🦽","name":"person in manual wheelchair: light skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["person","in","manual","wheelchair:","light","skin","tone"]}, + {"emoji":"🧑🏼‍🦽","name":"person in manual wheelchair: medium-light skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["person","in","manual","wheelchair:","medium-light","skin","tone"]}, + {"emoji":"🧑🏽‍🦽","name":"person in manual wheelchair: medium skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["person","in","manual","wheelchair:","medium","skin","tone"]}, + {"emoji":"🧑🏾‍🦽","name":"person in manual wheelchair: medium-dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["person","in","manual","wheelchair:","medium-dark","skin","tone"]}, + {"emoji":"🧑🏿‍🦽","name":"person in manual wheelchair: dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["person","in","manual","wheelchair:","dark","skin","tone"]}, + {"emoji":"🧑‍🦽‍➡️","name":"person in manual wheelchair facing right","category":"Smileys & People","version":"15.1","skinTone":false,"keywords":["person","in","manual","wheelchair","facing","right"]}, + {"emoji":"🧑🏻‍🦽‍➡️","name":"person in manual wheelchair facing right: light skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["person","in","manual","wheelchair","facing","right:","light","skin","tone"]}, + {"emoji":"🧑🏼‍🦽‍➡️","name":"person in manual wheelchair facing right: medium-light skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["person","in","manual","wheelchair","facing","right:","medium-light","skin","tone"]}, + {"emoji":"🧑🏽‍🦽‍➡️","name":"person in manual wheelchair facing right: medium skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["person","in","manual","wheelchair","facing","right:","medium","skin","tone"]}, + {"emoji":"🧑🏾‍🦽‍➡️","name":"person in manual wheelchair facing right: medium-dark skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["person","in","manual","wheelchair","facing","right:","medium-dark","skin","tone"]}, + {"emoji":"🧑🏿‍🦽‍➡️","name":"person in manual wheelchair facing right: dark skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["person","in","manual","wheelchair","facing","right:","dark","skin","tone"]}, + {"emoji":"👨‍🦽","name":"man in manual wheelchair","category":"Smileys & People","version":"12.0","skinTone":false,"keywords":["man","in","manual","wheelchair","accessibility"]}, + {"emoji":"👨🏻‍🦽","name":"man in manual wheelchair: light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["man","in","manual","wheelchair:","light","skin","tone"]}, + {"emoji":"👨🏼‍🦽","name":"man in manual wheelchair: medium-light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["man","in","manual","wheelchair:","medium-light","skin","tone"]}, + {"emoji":"👨🏽‍🦽","name":"man in manual wheelchair: medium skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["man","in","manual","wheelchair:","medium","skin","tone"]}, + {"emoji":"👨🏾‍🦽","name":"man in manual wheelchair: medium-dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["man","in","manual","wheelchair:","medium-dark","skin","tone"]}, + {"emoji":"👨🏿‍🦽","name":"man in manual wheelchair: dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["man","in","manual","wheelchair:","dark","skin","tone"]}, + {"emoji":"👨‍🦽‍➡️","name":"man in manual wheelchair facing right","category":"Smileys & People","version":"15.1","skinTone":false,"keywords":["man","in","manual","wheelchair","facing","right"]}, + {"emoji":"👨🏻‍🦽‍➡️","name":"man in manual wheelchair facing right: light skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["man","in","manual","wheelchair","facing","right:","light","skin","tone"]}, + {"emoji":"👨🏼‍🦽‍➡️","name":"man in manual wheelchair facing right: medium-light skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["man","in","manual","wheelchair","facing","right:","medium-light","skin","tone"]}, + {"emoji":"👨🏽‍🦽‍➡️","name":"man in manual wheelchair facing right: medium skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["man","in","manual","wheelchair","facing","right:","medium","skin","tone"]}, + {"emoji":"👨🏾‍🦽‍➡️","name":"man in manual wheelchair facing right: medium-dark skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["man","in","manual","wheelchair","facing","right:","medium-dark","skin","tone"]}, + {"emoji":"👨🏿‍🦽‍➡️","name":"man in manual wheelchair facing right: dark skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["man","in","manual","wheelchair","facing","right:","dark","skin","tone"]}, + {"emoji":"👩‍🦽","name":"woman in manual wheelchair","category":"Smileys & People","version":"12.0","skinTone":false,"keywords":["woman","in","manual","wheelchair","accessibility"]}, + {"emoji":"👩🏻‍🦽","name":"woman in manual wheelchair: light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","in","manual","wheelchair:","light","skin","tone"]}, + {"emoji":"👩🏼‍🦽","name":"woman in manual wheelchair: medium-light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","in","manual","wheelchair:","medium-light","skin","tone"]}, + {"emoji":"👩🏽‍🦽","name":"woman in manual wheelchair: medium skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","in","manual","wheelchair:","medium","skin","tone"]}, + {"emoji":"👩🏾‍🦽","name":"woman in manual wheelchair: medium-dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","in","manual","wheelchair:","medium-dark","skin","tone"]}, + {"emoji":"👩🏿‍🦽","name":"woman in manual wheelchair: dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","in","manual","wheelchair:","dark","skin","tone"]}, + {"emoji":"👩‍🦽‍➡️","name":"woman in manual wheelchair facing right","category":"Smileys & People","version":"15.1","skinTone":false,"keywords":["woman","in","manual","wheelchair","facing","right"]}, + {"emoji":"👩🏻‍🦽‍➡️","name":"woman in manual wheelchair facing right: light skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["woman","in","manual","wheelchair","facing","right:","light","skin","tone"]}, + {"emoji":"👩🏼‍🦽‍➡️","name":"woman in manual wheelchair facing right: medium-light skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["woman","in","manual","wheelchair","facing","right:","medium-light","skin","tone"]}, + {"emoji":"👩🏽‍🦽‍➡️","name":"woman in manual wheelchair facing right: medium skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["woman","in","manual","wheelchair","facing","right:","medium","skin","tone"]}, + {"emoji":"👩🏾‍🦽‍➡️","name":"woman in manual wheelchair facing right: medium-dark skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["woman","in","manual","wheelchair","facing","right:","medium-dark","skin","tone"]}, + {"emoji":"👩🏿‍🦽‍➡️","name":"woman in manual wheelchair facing right: dark skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["woman","in","manual","wheelchair","facing","right:","dark","skin","tone"]}, + {"emoji":"🏃","name":"person running","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["person","running","fast","hurry","marathon","move","quick","race","racing","run","rush","speed"]}, + {"emoji":"🏃🏻","name":"person running: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","running:","light","skin","tone"]}, + {"emoji":"🏃🏼","name":"person running: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","running:","medium-light","skin","tone"]}, + {"emoji":"🏃🏽","name":"person running: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","running:","medium","skin","tone"]}, + {"emoji":"🏃🏾","name":"person running: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","running:","medium-dark","skin","tone"]}, + {"emoji":"🏃🏿","name":"person running: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","running:","dark","skin","tone"]}, + {"emoji":"🏃‍♂️","name":"man running","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man","running"]}, + {"emoji":"🏃🏻‍♂️","name":"man running: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","running:","light","skin","tone"]}, + {"emoji":"🏃🏼‍♂️","name":"man running: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","running:","medium-light","skin","tone"]}, + {"emoji":"🏃🏽‍♂️","name":"man running: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","running:","medium","skin","tone"]}, + {"emoji":"🏃🏾‍♂️","name":"man running: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","running:","medium-dark","skin","tone"]}, + {"emoji":"🏃🏿‍♂️","name":"man running: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","running:","dark","skin","tone"]}, + {"emoji":"🏃‍♀️","name":"woman running","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman","running"]}, + {"emoji":"🏃🏻‍♀️","name":"woman running: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","running:","light","skin","tone"]}, + {"emoji":"🏃🏼‍♀️","name":"woman running: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","running:","medium-light","skin","tone"]}, + {"emoji":"🏃🏽‍♀️","name":"woman running: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","running:","medium","skin","tone"]}, + {"emoji":"🏃🏾‍♀️","name":"woman running: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","running:","medium-dark","skin","tone"]}, + {"emoji":"🏃🏿‍♀️","name":"woman running: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","running:","dark","skin","tone"]}, + {"emoji":"🏃‍➡️","name":"person running facing right","category":"Smileys & People","version":"15.1","skinTone":false,"keywords":["person","running","facing","right"]}, + {"emoji":"🏃🏻‍➡️","name":"person running facing right: light skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["person","running","facing","right:","light","skin","tone"]}, + {"emoji":"🏃🏼‍➡️","name":"person running facing right: medium-light skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["person","running","facing","right:","medium-light","skin","tone"]}, + {"emoji":"🏃🏽‍➡️","name":"person running facing right: medium skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["person","running","facing","right:","medium","skin","tone"]}, + {"emoji":"🏃🏾‍➡️","name":"person running facing right: medium-dark skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["person","running","facing","right:","medium-dark","skin","tone"]}, + {"emoji":"🏃🏿‍➡️","name":"person running facing right: dark skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["person","running","facing","right:","dark","skin","tone"]}, + {"emoji":"🏃‍♀️‍➡️","name":"woman running facing right","category":"Smileys & People","version":"15.1","skinTone":false,"keywords":["woman","running","facing","right"]}, + {"emoji":"🏃🏻‍♀️‍➡️","name":"woman running facing right: light skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["woman","running","facing","right:","light","skin","tone"]}, + {"emoji":"🏃🏼‍♀️‍➡️","name":"woman running facing right: medium-light skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["woman","running","facing","right:","medium-light","skin","tone"]}, + {"emoji":"🏃🏽‍♀️‍➡️","name":"woman running facing right: medium skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["woman","running","facing","right:","medium","skin","tone"]}, + {"emoji":"🏃🏾‍♀️‍➡️","name":"woman running facing right: medium-dark skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["woman","running","facing","right:","medium-dark","skin","tone"]}, + {"emoji":"🏃🏿‍♀️‍➡️","name":"woman running facing right: dark skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["woman","running","facing","right:","dark","skin","tone"]}, + {"emoji":"🏃‍♂️‍➡️","name":"man running facing right","category":"Smileys & People","version":"15.1","skinTone":false,"keywords":["man","running","facing","right"]}, + {"emoji":"🏃🏻‍♂️‍➡️","name":"man running facing right: light skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["man","running","facing","right:","light","skin","tone"]}, + {"emoji":"🏃🏼‍♂️‍➡️","name":"man running facing right: medium-light skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["man","running","facing","right:","medium-light","skin","tone"]}, + {"emoji":"🏃🏽‍♂️‍➡️","name":"man running facing right: medium skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["man","running","facing","right:","medium","skin","tone"]}, + {"emoji":"🏃🏾‍♂️‍➡️","name":"man running facing right: medium-dark skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["man","running","facing","right:","medium-dark","skin","tone"]}, + {"emoji":"🏃🏿‍♂️‍➡️","name":"man running facing right: dark skin tone","category":"Smileys & People","version":"15.1","skinTone":true,"keywords":["man","running","facing","right:","dark","skin","tone"]}, + {"emoji":"🧑‍🩰","name":"ballet dancer","category":"Smileys & People","version":"17.0","skinTone":false,"keywords":["ballet","dancer"]}, + {"emoji":"🧑🏻‍🩰","name":"ballet dancer: light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["ballet","dancer:","light","skin","tone"]}, + {"emoji":"🧑🏼‍🩰","name":"ballet dancer: medium-light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["ballet","dancer:","medium-light","skin","tone"]}, + {"emoji":"🧑🏽‍🩰","name":"ballet dancer: medium skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["ballet","dancer:","medium","skin","tone"]}, + {"emoji":"🧑🏾‍🩰","name":"ballet dancer: medium-dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["ballet","dancer:","medium-dark","skin","tone"]}, + {"emoji":"🧑🏿‍🩰","name":"ballet dancer: dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["ballet","dancer:","dark","skin","tone"]}, + {"emoji":"💃","name":"woman dancing","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["woman","dancing","dance","dancer","elegant","festive","flair","flamenco","groove","let’s","salsa","tango"]}, + {"emoji":"💃🏻","name":"woman dancing: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["woman","dancing:","light","skin","tone"]}, + {"emoji":"💃🏼","name":"woman dancing: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["woman","dancing:","medium-light","skin","tone"]}, + {"emoji":"💃🏽","name":"woman dancing: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["woman","dancing:","medium","skin","tone"]}, + {"emoji":"💃🏾","name":"woman dancing: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["woman","dancing:","medium-dark","skin","tone"]}, + {"emoji":"💃🏿","name":"woman dancing: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["woman","dancing:","dark","skin","tone"]}, + {"emoji":"🕺","name":"man dancing","category":"Smileys & People","version":"3.0","skinTone":false,"keywords":["man","dancing","dance","dancer","elegant","festive","flair","flamenco","groove","let’s","salsa","tango"]}, + {"emoji":"🕺🏻","name":"man dancing: light skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["man","dancing:","light","skin","tone"]}, + {"emoji":"🕺🏼","name":"man dancing: medium-light skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["man","dancing:","medium-light","skin","tone"]}, + {"emoji":"🕺🏽","name":"man dancing: medium skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["man","dancing:","medium","skin","tone"]}, + {"emoji":"🕺🏾","name":"man dancing: medium-dark skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["man","dancing:","medium-dark","skin","tone"]}, + {"emoji":"🕺🏿","name":"man dancing: dark skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["man","dancing:","dark","skin","tone"]}, + {"emoji":"🕴️","name":"person in suit levitating","category":"Smileys & People","version":"0.7","skinTone":false,"keywords":["person","in","suit","levitating"]}, + {"emoji":"🕴🏻","name":"person in suit levitating: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["person","in","suit","levitating:","light","skin","tone"]}, + {"emoji":"🕴🏼","name":"person in suit levitating: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["person","in","suit","levitating:","medium-light","skin","tone"]}, + {"emoji":"🕴🏽","name":"person in suit levitating: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["person","in","suit","levitating:","medium","skin","tone"]}, + {"emoji":"🕴🏾","name":"person in suit levitating: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["person","in","suit","levitating:","medium-dark","skin","tone"]}, + {"emoji":"🕴🏿","name":"person in suit levitating: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["person","in","suit","levitating:","dark","skin","tone"]}, + {"emoji":"👯","name":"people with bunny ears","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["people","with","bunny","ears","bestie","bff","counterpart","dancer","double","ear","identical","pair","party","partying","soulmate","twin","twinsies"]}, + {"emoji":"👯🏻","name":"people with bunny ears: light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","with","bunny","ears:","light","skin","tone"]}, + {"emoji":"👯🏼","name":"people with bunny ears: medium-light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","with","bunny","ears:","medium-light","skin","tone"]}, + {"emoji":"👯🏽","name":"people with bunny ears: medium skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","with","bunny","ears:","medium","skin","tone"]}, + {"emoji":"👯🏾","name":"people with bunny ears: medium-dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","with","bunny","ears:","medium-dark","skin","tone"]}, + {"emoji":"👯🏿","name":"people with bunny ears: dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","with","bunny","ears:","dark","skin","tone"]}, + {"emoji":"👯‍♂️","name":"men with bunny ears","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["men","with","bunny","ears"]}, + {"emoji":"👯🏻‍♂️","name":"men with bunny ears: light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","with","bunny","ears:","light","skin","tone"]}, + {"emoji":"👯🏼‍♂️","name":"men with bunny ears: medium-light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","with","bunny","ears:","medium-light","skin","tone"]}, + {"emoji":"👯🏽‍♂️","name":"men with bunny ears: medium skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","with","bunny","ears:","medium","skin","tone"]}, + {"emoji":"👯🏾‍♂️","name":"men with bunny ears: medium-dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","with","bunny","ears:","medium-dark","skin","tone"]}, + {"emoji":"👯🏿‍♂️","name":"men with bunny ears: dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","with","bunny","ears:","dark","skin","tone"]}, + {"emoji":"👯‍♀️","name":"women with bunny ears","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["women","with","bunny","ears"]}, + {"emoji":"👯🏻‍♀️","name":"women with bunny ears: light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","with","bunny","ears:","light","skin","tone"]}, + {"emoji":"👯🏼‍♀️","name":"women with bunny ears: medium-light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","with","bunny","ears:","medium-light","skin","tone"]}, + {"emoji":"👯🏽‍♀️","name":"women with bunny ears: medium skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","with","bunny","ears:","medium","skin","tone"]}, + {"emoji":"👯🏾‍♀️","name":"women with bunny ears: medium-dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","with","bunny","ears:","medium-dark","skin","tone"]}, + {"emoji":"👯🏿‍♀️","name":"women with bunny ears: dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","with","bunny","ears:","dark","skin","tone"]}, + {"emoji":"🧑🏻‍🐰‍🧑🏼","name":"people with bunny ears: light skin tone, medium-light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","with","bunny","ears:","light","skin","tone,","medium-light","tone"]}, + {"emoji":"🧑🏻‍🐰‍🧑🏽","name":"people with bunny ears: light skin tone, medium skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","with","bunny","ears:","light","skin","tone,","medium","tone"]}, + {"emoji":"🧑🏻‍🐰‍🧑🏾","name":"people with bunny ears: light skin tone, medium-dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","with","bunny","ears:","light","skin","tone,","medium-dark","tone"]}, + {"emoji":"🧑🏻‍🐰‍🧑🏿","name":"people with bunny ears: light skin tone, dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","with","bunny","ears:","light","skin","tone,","dark","tone"]}, + {"emoji":"🧑🏼‍🐰‍🧑🏻","name":"people with bunny ears: medium-light skin tone, light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","with","bunny","ears:","medium-light","skin","tone,","light","tone"]}, + {"emoji":"🧑🏼‍🐰‍🧑🏽","name":"people with bunny ears: medium-light skin tone, medium skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","with","bunny","ears:","medium-light","skin","tone,","medium","tone"]}, + {"emoji":"🧑🏼‍🐰‍🧑🏾","name":"people with bunny ears: medium-light skin tone, medium-dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","with","bunny","ears:","medium-light","skin","tone,","medium-dark","tone"]}, + {"emoji":"🧑🏼‍🐰‍🧑🏿","name":"people with bunny ears: medium-light skin tone, dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","with","bunny","ears:","medium-light","skin","tone,","dark","tone"]}, + {"emoji":"🧑🏽‍🐰‍🧑🏻","name":"people with bunny ears: medium skin tone, light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","with","bunny","ears:","medium","skin","tone,","light","tone"]}, + {"emoji":"🧑🏽‍🐰‍🧑🏼","name":"people with bunny ears: medium skin tone, medium-light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","with","bunny","ears:","medium","skin","tone,","medium-light","tone"]}, + {"emoji":"🧑🏽‍🐰‍🧑🏾","name":"people with bunny ears: medium skin tone, medium-dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","with","bunny","ears:","medium","skin","tone,","medium-dark","tone"]}, + {"emoji":"🧑🏽‍🐰‍🧑🏿","name":"people with bunny ears: medium skin tone, dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","with","bunny","ears:","medium","skin","tone,","dark","tone"]}, + {"emoji":"🧑🏾‍🐰‍🧑🏻","name":"people with bunny ears: medium-dark skin tone, light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","with","bunny","ears:","medium-dark","skin","tone,","light","tone"]}, + {"emoji":"🧑🏾‍🐰‍🧑🏼","name":"people with bunny ears: medium-dark skin tone, medium-light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","with","bunny","ears:","medium-dark","skin","tone,","medium-light","tone"]}, + {"emoji":"🧑🏾‍🐰‍🧑🏽","name":"people with bunny ears: medium-dark skin tone, medium skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","with","bunny","ears:","medium-dark","skin","tone,","medium","tone"]}, + {"emoji":"🧑🏾‍🐰‍🧑🏿","name":"people with bunny ears: medium-dark skin tone, dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","with","bunny","ears:","medium-dark","skin","tone,","dark","tone"]}, + {"emoji":"🧑🏿‍🐰‍🧑🏻","name":"people with bunny ears: dark skin tone, light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","with","bunny","ears:","dark","skin","tone,","light","tone"]}, + {"emoji":"🧑🏿‍🐰‍🧑🏼","name":"people with bunny ears: dark skin tone, medium-light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","with","bunny","ears:","dark","skin","tone,","medium-light","tone"]}, + {"emoji":"🧑🏿‍🐰‍🧑🏽","name":"people with bunny ears: dark skin tone, medium skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","with","bunny","ears:","dark","skin","tone,","medium","tone"]}, + {"emoji":"🧑🏿‍🐰‍🧑🏾","name":"people with bunny ears: dark skin tone, medium-dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","with","bunny","ears:","dark","skin","tone,","medium-dark","tone"]}, + {"emoji":"👨🏻‍🐰‍👨🏼","name":"men with bunny ears: light skin tone, medium-light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","with","bunny","ears:","light","skin","tone,","medium-light","tone"]}, + {"emoji":"👨🏻‍🐰‍👨🏽","name":"men with bunny ears: light skin tone, medium skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","with","bunny","ears:","light","skin","tone,","medium","tone"]}, + {"emoji":"👨🏻‍🐰‍👨🏾","name":"men with bunny ears: light skin tone, medium-dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","with","bunny","ears:","light","skin","tone,","medium-dark","tone"]}, + {"emoji":"👨🏻‍🐰‍👨🏿","name":"men with bunny ears: light skin tone, dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","with","bunny","ears:","light","skin","tone,","dark","tone"]}, + {"emoji":"👨🏼‍🐰‍👨🏻","name":"men with bunny ears: medium-light skin tone, light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","with","bunny","ears:","medium-light","skin","tone,","light","tone"]}, + {"emoji":"👨🏼‍🐰‍👨🏽","name":"men with bunny ears: medium-light skin tone, medium skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","with","bunny","ears:","medium-light","skin","tone,","medium","tone"]}, + {"emoji":"👨🏼‍🐰‍👨🏾","name":"men with bunny ears: medium-light skin tone, medium-dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","with","bunny","ears:","medium-light","skin","tone,","medium-dark","tone"]}, + {"emoji":"👨🏼‍🐰‍👨🏿","name":"men with bunny ears: medium-light skin tone, dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","with","bunny","ears:","medium-light","skin","tone,","dark","tone"]}, + {"emoji":"👨🏽‍🐰‍👨🏻","name":"men with bunny ears: medium skin tone, light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","with","bunny","ears:","medium","skin","tone,","light","tone"]}, + {"emoji":"👨🏽‍🐰‍👨🏼","name":"men with bunny ears: medium skin tone, medium-light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","with","bunny","ears:","medium","skin","tone,","medium-light","tone"]}, + {"emoji":"👨🏽‍🐰‍👨🏾","name":"men with bunny ears: medium skin tone, medium-dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","with","bunny","ears:","medium","skin","tone,","medium-dark","tone"]}, + {"emoji":"👨🏽‍🐰‍👨🏿","name":"men with bunny ears: medium skin tone, dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","with","bunny","ears:","medium","skin","tone,","dark","tone"]}, + {"emoji":"👨🏾‍🐰‍👨🏻","name":"men with bunny ears: medium-dark skin tone, light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","with","bunny","ears:","medium-dark","skin","tone,","light","tone"]}, + {"emoji":"👨🏾‍🐰‍👨🏼","name":"men with bunny ears: medium-dark skin tone, medium-light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","with","bunny","ears:","medium-dark","skin","tone,","medium-light","tone"]}, + {"emoji":"👨🏾‍🐰‍👨🏽","name":"men with bunny ears: medium-dark skin tone, medium skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","with","bunny","ears:","medium-dark","skin","tone,","medium","tone"]}, + {"emoji":"👨🏾‍🐰‍👨🏿","name":"men with bunny ears: medium-dark skin tone, dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","with","bunny","ears:","medium-dark","skin","tone,","dark","tone"]}, + {"emoji":"👨🏿‍🐰‍👨🏻","name":"men with bunny ears: dark skin tone, light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","with","bunny","ears:","dark","skin","tone,","light","tone"]}, + {"emoji":"👨🏿‍🐰‍👨🏼","name":"men with bunny ears: dark skin tone, medium-light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","with","bunny","ears:","dark","skin","tone,","medium-light","tone"]}, + {"emoji":"👨🏿‍🐰‍👨🏽","name":"men with bunny ears: dark skin tone, medium skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","with","bunny","ears:","dark","skin","tone,","medium","tone"]}, + {"emoji":"👨🏿‍🐰‍👨🏾","name":"men with bunny ears: dark skin tone, medium-dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","with","bunny","ears:","dark","skin","tone,","medium-dark","tone"]}, + {"emoji":"👩🏻‍🐰‍👩🏼","name":"women with bunny ears: light skin tone, medium-light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","with","bunny","ears:","light","skin","tone,","medium-light","tone"]}, + {"emoji":"👩🏻‍🐰‍👩🏽","name":"women with bunny ears: light skin tone, medium skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","with","bunny","ears:","light","skin","tone,","medium","tone"]}, + {"emoji":"👩🏻‍🐰‍👩🏾","name":"women with bunny ears: light skin tone, medium-dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","with","bunny","ears:","light","skin","tone,","medium-dark","tone"]}, + {"emoji":"👩🏻‍🐰‍👩🏿","name":"women with bunny ears: light skin tone, dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","with","bunny","ears:","light","skin","tone,","dark","tone"]}, + {"emoji":"👩🏼‍🐰‍👩🏻","name":"women with bunny ears: medium-light skin tone, light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","with","bunny","ears:","medium-light","skin","tone,","light","tone"]}, + {"emoji":"👩🏼‍🐰‍👩🏽","name":"women with bunny ears: medium-light skin tone, medium skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","with","bunny","ears:","medium-light","skin","tone,","medium","tone"]}, + {"emoji":"👩🏼‍🐰‍👩🏾","name":"women with bunny ears: medium-light skin tone, medium-dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","with","bunny","ears:","medium-light","skin","tone,","medium-dark","tone"]}, + {"emoji":"👩🏼‍🐰‍👩🏿","name":"women with bunny ears: medium-light skin tone, dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","with","bunny","ears:","medium-light","skin","tone,","dark","tone"]}, + {"emoji":"👩🏽‍🐰‍👩🏻","name":"women with bunny ears: medium skin tone, light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","with","bunny","ears:","medium","skin","tone,","light","tone"]}, + {"emoji":"👩🏽‍🐰‍👩🏼","name":"women with bunny ears: medium skin tone, medium-light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","with","bunny","ears:","medium","skin","tone,","medium-light","tone"]}, + {"emoji":"👩🏽‍🐰‍👩🏾","name":"women with bunny ears: medium skin tone, medium-dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","with","bunny","ears:","medium","skin","tone,","medium-dark","tone"]}, + {"emoji":"👩🏽‍🐰‍👩🏿","name":"women with bunny ears: medium skin tone, dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","with","bunny","ears:","medium","skin","tone,","dark","tone"]}, + {"emoji":"👩🏾‍🐰‍👩🏻","name":"women with bunny ears: medium-dark skin tone, light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","with","bunny","ears:","medium-dark","skin","tone,","light","tone"]}, + {"emoji":"👩🏾‍🐰‍👩🏼","name":"women with bunny ears: medium-dark skin tone, medium-light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","with","bunny","ears:","medium-dark","skin","tone,","medium-light","tone"]}, + {"emoji":"👩🏾‍🐰‍👩🏽","name":"women with bunny ears: medium-dark skin tone, medium skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","with","bunny","ears:","medium-dark","skin","tone,","medium","tone"]}, + {"emoji":"👩🏾‍🐰‍👩🏿","name":"women with bunny ears: medium-dark skin tone, dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","with","bunny","ears:","medium-dark","skin","tone,","dark","tone"]}, + {"emoji":"👩🏿‍🐰‍👩🏻","name":"women with bunny ears: dark skin tone, light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","with","bunny","ears:","dark","skin","tone,","light","tone"]}, + {"emoji":"👩🏿‍🐰‍👩🏼","name":"women with bunny ears: dark skin tone, medium-light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","with","bunny","ears:","dark","skin","tone,","medium-light","tone"]}, + {"emoji":"👩🏿‍🐰‍👩🏽","name":"women with bunny ears: dark skin tone, medium skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","with","bunny","ears:","dark","skin","tone,","medium","tone"]}, + {"emoji":"👩🏿‍🐰‍👩🏾","name":"women with bunny ears: dark skin tone, medium-dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","with","bunny","ears:","dark","skin","tone,","medium-dark","tone"]}, + {"emoji":"🧖","name":"person in steamy room","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["person","in","steamy","room","day","luxurious","pamper","relax","sauna","spa","steam","steambath","unwind"]}, + {"emoji":"🧖🏻","name":"person in steamy room: light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["person","in","steamy","room:","light","skin","tone"]}, + {"emoji":"🧖🏼","name":"person in steamy room: medium-light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["person","in","steamy","room:","medium-light","skin","tone"]}, + {"emoji":"🧖🏽","name":"person in steamy room: medium skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["person","in","steamy","room:","medium","skin","tone"]}, + {"emoji":"🧖🏾","name":"person in steamy room: medium-dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["person","in","steamy","room:","medium-dark","skin","tone"]}, + {"emoji":"🧖🏿","name":"person in steamy room: dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["person","in","steamy","room:","dark","skin","tone"]}, + {"emoji":"🧖‍♂️","name":"man in steamy room","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["man","in","steamy","room"]}, + {"emoji":"🧖🏻‍♂️","name":"man in steamy room: light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["man","in","steamy","room:","light","skin","tone"]}, + {"emoji":"🧖🏼‍♂️","name":"man in steamy room: medium-light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["man","in","steamy","room:","medium-light","skin","tone"]}, + {"emoji":"🧖🏽‍♂️","name":"man in steamy room: medium skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["man","in","steamy","room:","medium","skin","tone"]}, + {"emoji":"🧖🏾‍♂️","name":"man in steamy room: medium-dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["man","in","steamy","room:","medium-dark","skin","tone"]}, + {"emoji":"🧖🏿‍♂️","name":"man in steamy room: dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["man","in","steamy","room:","dark","skin","tone"]}, + {"emoji":"🧖‍♀️","name":"woman in steamy room","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["woman","in","steamy","room"]}, + {"emoji":"🧖🏻‍♀️","name":"woman in steamy room: light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["woman","in","steamy","room:","light","skin","tone"]}, + {"emoji":"🧖🏼‍♀️","name":"woman in steamy room: medium-light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["woman","in","steamy","room:","medium-light","skin","tone"]}, + {"emoji":"🧖🏽‍♀️","name":"woman in steamy room: medium skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["woman","in","steamy","room:","medium","skin","tone"]}, + {"emoji":"🧖🏾‍♀️","name":"woman in steamy room: medium-dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["woman","in","steamy","room:","medium-dark","skin","tone"]}, + {"emoji":"🧖🏿‍♀️","name":"woman in steamy room: dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["woman","in","steamy","room:","dark","skin","tone"]}, + {"emoji":"🧗","name":"person climbing","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["person","climbing","climb","climber","mountain","rock","scale","up"]}, + {"emoji":"🧗🏻","name":"person climbing: light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["person","climbing:","light","skin","tone"]}, + {"emoji":"🧗🏼","name":"person climbing: medium-light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["person","climbing:","medium-light","skin","tone"]}, + {"emoji":"🧗🏽","name":"person climbing: medium skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["person","climbing:","medium","skin","tone"]}, + {"emoji":"🧗🏾","name":"person climbing: medium-dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["person","climbing:","medium-dark","skin","tone"]}, + {"emoji":"🧗🏿","name":"person climbing: dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["person","climbing:","dark","skin","tone"]}, + {"emoji":"🧗‍♂️","name":"man climbing","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["man","climbing"]}, + {"emoji":"🧗🏻‍♂️","name":"man climbing: light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["man","climbing:","light","skin","tone"]}, + {"emoji":"🧗🏼‍♂️","name":"man climbing: medium-light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["man","climbing:","medium-light","skin","tone"]}, + {"emoji":"🧗🏽‍♂️","name":"man climbing: medium skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["man","climbing:","medium","skin","tone"]}, + {"emoji":"🧗🏾‍♂️","name":"man climbing: medium-dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["man","climbing:","medium-dark","skin","tone"]}, + {"emoji":"🧗🏿‍♂️","name":"man climbing: dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["man","climbing:","dark","skin","tone"]}, + {"emoji":"🧗‍♀️","name":"woman climbing","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["woman","climbing"]}, + {"emoji":"🧗🏻‍♀️","name":"woman climbing: light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["woman","climbing:","light","skin","tone"]}, + {"emoji":"🧗🏼‍♀️","name":"woman climbing: medium-light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["woman","climbing:","medium-light","skin","tone"]}, + {"emoji":"🧗🏽‍♀️","name":"woman climbing: medium skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["woman","climbing:","medium","skin","tone"]}, + {"emoji":"🧗🏾‍♀️","name":"woman climbing: medium-dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["woman","climbing:","medium-dark","skin","tone"]}, + {"emoji":"🧗🏿‍♀️","name":"woman climbing: dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["woman","climbing:","dark","skin","tone"]}, + {"emoji":"🤺","name":"person fencing","category":"Smileys & People","version":"3.0","skinTone":false,"keywords":["person","fencing","fencer","sword"]}, + {"emoji":"🏇","name":"horse racing","category":"Smileys & People","version":"1.0","skinTone":false,"keywords":["horse","racing","jockey","racehorse","riding","sport"]}, + {"emoji":"🏇🏻","name":"horse racing: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["horse","racing:","light","skin","tone"]}, + {"emoji":"🏇🏼","name":"horse racing: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["horse","racing:","medium-light","skin","tone"]}, + {"emoji":"🏇🏽","name":"horse racing: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["horse","racing:","medium","skin","tone"]}, + {"emoji":"🏇🏾","name":"horse racing: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["horse","racing:","medium-dark","skin","tone"]}, + {"emoji":"🏇🏿","name":"horse racing: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["horse","racing:","dark","skin","tone"]}, + {"emoji":"⛷️","name":"skier","category":"Smileys & People","version":"0.7","skinTone":false,"keywords":["skier"]}, + {"emoji":"🏂","name":"snowboarder","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["snowboarder","ski","snow","snowboard","sport"]}, + {"emoji":"🏂🏻","name":"snowboarder: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["snowboarder:","light","skin","tone"]}, + {"emoji":"🏂🏼","name":"snowboarder: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["snowboarder:","medium-light","skin","tone"]}, + {"emoji":"🏂🏽","name":"snowboarder: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["snowboarder:","medium","skin","tone"]}, + {"emoji":"🏂🏾","name":"snowboarder: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["snowboarder:","medium-dark","skin","tone"]}, + {"emoji":"🏂🏿","name":"snowboarder: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["snowboarder:","dark","skin","tone"]}, + {"emoji":"🏌️","name":"person golfing","category":"Smileys & People","version":"0.7","skinTone":false,"keywords":["person","golfing"]}, + {"emoji":"🏌🏻","name":"person golfing: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["person","golfing:","light","skin","tone"]}, + {"emoji":"🏌🏼","name":"person golfing: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["person","golfing:","medium-light","skin","tone"]}, + {"emoji":"🏌🏽","name":"person golfing: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["person","golfing:","medium","skin","tone"]}, + {"emoji":"🏌🏾","name":"person golfing: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["person","golfing:","medium-dark","skin","tone"]}, + {"emoji":"🏌🏿","name":"person golfing: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["person","golfing:","dark","skin","tone"]}, + {"emoji":"🏌️‍♂️","name":"man golfing","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man","golfing"]}, + {"emoji":"🏌🏻‍♂️","name":"man golfing: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","golfing:","light","skin","tone"]}, + {"emoji":"🏌🏼‍♂️","name":"man golfing: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","golfing:","medium-light","skin","tone"]}, + {"emoji":"🏌🏽‍♂️","name":"man golfing: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","golfing:","medium","skin","tone"]}, + {"emoji":"🏌🏾‍♂️","name":"man golfing: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","golfing:","medium-dark","skin","tone"]}, + {"emoji":"🏌🏿‍♂️","name":"man golfing: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","golfing:","dark","skin","tone"]}, + {"emoji":"🏌️‍♀️","name":"woman golfing","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman","golfing"]}, + {"emoji":"🏌🏻‍♀️","name":"woman golfing: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","golfing:","light","skin","tone"]}, + {"emoji":"🏌🏼‍♀️","name":"woman golfing: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","golfing:","medium-light","skin","tone"]}, + {"emoji":"🏌🏽‍♀️","name":"woman golfing: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","golfing:","medium","skin","tone"]}, + {"emoji":"🏌🏾‍♀️","name":"woman golfing: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","golfing:","medium-dark","skin","tone"]}, + {"emoji":"🏌🏿‍♀️","name":"woman golfing: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","golfing:","dark","skin","tone"]}, + {"emoji":"🏄","name":"person surfing","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["person","surfing","beach","ocean","sport","surf","surfer","swell","waves"]}, + {"emoji":"🏄🏻","name":"person surfing: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","surfing:","light","skin","tone"]}, + {"emoji":"🏄🏼","name":"person surfing: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","surfing:","medium-light","skin","tone"]}, + {"emoji":"🏄🏽","name":"person surfing: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","surfing:","medium","skin","tone"]}, + {"emoji":"🏄🏾","name":"person surfing: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","surfing:","medium-dark","skin","tone"]}, + {"emoji":"🏄🏿","name":"person surfing: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","surfing:","dark","skin","tone"]}, + {"emoji":"🏄‍♂️","name":"man surfing","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man","surfing"]}, + {"emoji":"🏄🏻‍♂️","name":"man surfing: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","surfing:","light","skin","tone"]}, + {"emoji":"🏄🏼‍♂️","name":"man surfing: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","surfing:","medium-light","skin","tone"]}, + {"emoji":"🏄🏽‍♂️","name":"man surfing: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","surfing:","medium","skin","tone"]}, + {"emoji":"🏄🏾‍♂️","name":"man surfing: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","surfing:","medium-dark","skin","tone"]}, + {"emoji":"🏄🏿‍♂️","name":"man surfing: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","surfing:","dark","skin","tone"]}, + {"emoji":"🏄‍♀️","name":"woman surfing","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman","surfing"]}, + {"emoji":"🏄🏻‍♀️","name":"woman surfing: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","surfing:","light","skin","tone"]}, + {"emoji":"🏄🏼‍♀️","name":"woman surfing: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","surfing:","medium-light","skin","tone"]}, + {"emoji":"🏄🏽‍♀️","name":"woman surfing: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","surfing:","medium","skin","tone"]}, + {"emoji":"🏄🏾‍♀️","name":"woman surfing: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","surfing:","medium-dark","skin","tone"]}, + {"emoji":"🏄🏿‍♀️","name":"woman surfing: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","surfing:","dark","skin","tone"]}, + {"emoji":"🚣","name":"person rowing boat","category":"Smileys & People","version":"1.0","skinTone":false,"keywords":["person","rowing","boat","canoe","cruise","fishing","lake","oar","paddle","raft","river","row","rowboat"]}, + {"emoji":"🚣🏻","name":"person rowing boat: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","rowing","boat:","light","skin","tone"]}, + {"emoji":"🚣🏼","name":"person rowing boat: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","rowing","boat:","medium-light","skin","tone"]}, + {"emoji":"🚣🏽","name":"person rowing boat: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","rowing","boat:","medium","skin","tone"]}, + {"emoji":"🚣🏾","name":"person rowing boat: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","rowing","boat:","medium-dark","skin","tone"]}, + {"emoji":"🚣🏿","name":"person rowing boat: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","rowing","boat:","dark","skin","tone"]}, + {"emoji":"🚣‍♂️","name":"man rowing boat","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man","rowing","boat"]}, + {"emoji":"🚣🏻‍♂️","name":"man rowing boat: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","rowing","boat:","light","skin","tone"]}, + {"emoji":"🚣🏼‍♂️","name":"man rowing boat: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","rowing","boat:","medium-light","skin","tone"]}, + {"emoji":"🚣🏽‍♂️","name":"man rowing boat: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","rowing","boat:","medium","skin","tone"]}, + {"emoji":"🚣🏾‍♂️","name":"man rowing boat: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","rowing","boat:","medium-dark","skin","tone"]}, + {"emoji":"🚣🏿‍♂️","name":"man rowing boat: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","rowing","boat:","dark","skin","tone"]}, + {"emoji":"🚣‍♀️","name":"woman rowing boat","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman","rowing","boat"]}, + {"emoji":"🚣🏻‍♀️","name":"woman rowing boat: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","rowing","boat:","light","skin","tone"]}, + {"emoji":"🚣🏼‍♀️","name":"woman rowing boat: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","rowing","boat:","medium-light","skin","tone"]}, + {"emoji":"🚣🏽‍♀️","name":"woman rowing boat: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","rowing","boat:","medium","skin","tone"]}, + {"emoji":"🚣🏾‍♀️","name":"woman rowing boat: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","rowing","boat:","medium-dark","skin","tone"]}, + {"emoji":"🚣🏿‍♀️","name":"woman rowing boat: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","rowing","boat:","dark","skin","tone"]}, + {"emoji":"🏊","name":"person swimming","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["person","swimming","freestyle","sport","swim","swimmer","triathlon"]}, + {"emoji":"🏊🏻","name":"person swimming: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","swimming:","light","skin","tone"]}, + {"emoji":"🏊🏼","name":"person swimming: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","swimming:","medium-light","skin","tone"]}, + {"emoji":"🏊🏽","name":"person swimming: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","swimming:","medium","skin","tone"]}, + {"emoji":"🏊🏾","name":"person swimming: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","swimming:","medium-dark","skin","tone"]}, + {"emoji":"🏊🏿","name":"person swimming: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","swimming:","dark","skin","tone"]}, + {"emoji":"🏊‍♂️","name":"man swimming","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man","swimming"]}, + {"emoji":"🏊🏻‍♂️","name":"man swimming: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","swimming:","light","skin","tone"]}, + {"emoji":"🏊🏼‍♂️","name":"man swimming: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","swimming:","medium-light","skin","tone"]}, + {"emoji":"🏊🏽‍♂️","name":"man swimming: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","swimming:","medium","skin","tone"]}, + {"emoji":"🏊🏾‍♂️","name":"man swimming: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","swimming:","medium-dark","skin","tone"]}, + {"emoji":"🏊🏿‍♂️","name":"man swimming: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","swimming:","dark","skin","tone"]}, + {"emoji":"🏊‍♀️","name":"woman swimming","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman","swimming"]}, + {"emoji":"🏊🏻‍♀️","name":"woman swimming: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","swimming:","light","skin","tone"]}, + {"emoji":"🏊🏼‍♀️","name":"woman swimming: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","swimming:","medium-light","skin","tone"]}, + {"emoji":"🏊🏽‍♀️","name":"woman swimming: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","swimming:","medium","skin","tone"]}, + {"emoji":"🏊🏾‍♀️","name":"woman swimming: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","swimming:","medium-dark","skin","tone"]}, + {"emoji":"🏊🏿‍♀️","name":"woman swimming: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","swimming:","dark","skin","tone"]}, + {"emoji":"⛹️","name":"person bouncing ball","category":"Smileys & People","version":"0.7","skinTone":false,"keywords":["person","bouncing","ball"]}, + {"emoji":"⛹🏻","name":"person bouncing ball: light skin tone","category":"Smileys & People","version":"2.0","skinTone":true,"keywords":["person","bouncing","ball:","light","skin","tone"]}, + {"emoji":"⛹🏼","name":"person bouncing ball: medium-light skin tone","category":"Smileys & People","version":"2.0","skinTone":true,"keywords":["person","bouncing","ball:","medium-light","skin","tone"]}, + {"emoji":"⛹🏽","name":"person bouncing ball: medium skin tone","category":"Smileys & People","version":"2.0","skinTone":true,"keywords":["person","bouncing","ball:","medium","skin","tone"]}, + {"emoji":"⛹🏾","name":"person bouncing ball: medium-dark skin tone","category":"Smileys & People","version":"2.0","skinTone":true,"keywords":["person","bouncing","ball:","medium-dark","skin","tone"]}, + {"emoji":"⛹🏿","name":"person bouncing ball: dark skin tone","category":"Smileys & People","version":"2.0","skinTone":true,"keywords":["person","bouncing","ball:","dark","skin","tone"]}, + {"emoji":"⛹️‍♂️","name":"man bouncing ball","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man","bouncing","ball"]}, + {"emoji":"⛹🏻‍♂️","name":"man bouncing ball: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","bouncing","ball:","light","skin","tone"]}, + {"emoji":"⛹🏼‍♂️","name":"man bouncing ball: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","bouncing","ball:","medium-light","skin","tone"]}, + {"emoji":"⛹🏽‍♂️","name":"man bouncing ball: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","bouncing","ball:","medium","skin","tone"]}, + {"emoji":"⛹🏾‍♂️","name":"man bouncing ball: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","bouncing","ball:","medium-dark","skin","tone"]}, + {"emoji":"⛹🏿‍♂️","name":"man bouncing ball: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","bouncing","ball:","dark","skin","tone"]}, + {"emoji":"⛹️‍♀️","name":"woman bouncing ball","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman","bouncing","ball"]}, + {"emoji":"⛹🏻‍♀️","name":"woman bouncing ball: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","bouncing","ball:","light","skin","tone"]}, + {"emoji":"⛹🏼‍♀️","name":"woman bouncing ball: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","bouncing","ball:","medium-light","skin","tone"]}, + {"emoji":"⛹🏽‍♀️","name":"woman bouncing ball: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","bouncing","ball:","medium","skin","tone"]}, + {"emoji":"⛹🏾‍♀️","name":"woman bouncing ball: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","bouncing","ball:","medium-dark","skin","tone"]}, + {"emoji":"⛹🏿‍♀️","name":"woman bouncing ball: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","bouncing","ball:","dark","skin","tone"]}, + {"emoji":"🏋️","name":"person lifting weights","category":"Smileys & People","version":"0.7","skinTone":false,"keywords":["person","lifting","weights"]}, + {"emoji":"🏋🏻","name":"person lifting weights: light skin tone","category":"Smileys & People","version":"2.0","skinTone":true,"keywords":["person","lifting","weights:","light","skin","tone"]}, + {"emoji":"🏋🏼","name":"person lifting weights: medium-light skin tone","category":"Smileys & People","version":"2.0","skinTone":true,"keywords":["person","lifting","weights:","medium-light","skin","tone"]}, + {"emoji":"🏋🏽","name":"person lifting weights: medium skin tone","category":"Smileys & People","version":"2.0","skinTone":true,"keywords":["person","lifting","weights:","medium","skin","tone"]}, + {"emoji":"🏋🏾","name":"person lifting weights: medium-dark skin tone","category":"Smileys & People","version":"2.0","skinTone":true,"keywords":["person","lifting","weights:","medium-dark","skin","tone"]}, + {"emoji":"🏋🏿","name":"person lifting weights: dark skin tone","category":"Smileys & People","version":"2.0","skinTone":true,"keywords":["person","lifting","weights:","dark","skin","tone"]}, + {"emoji":"🏋️‍♂️","name":"man lifting weights","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man","lifting","weights"]}, + {"emoji":"🏋🏻‍♂️","name":"man lifting weights: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","lifting","weights:","light","skin","tone"]}, + {"emoji":"🏋🏼‍♂️","name":"man lifting weights: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","lifting","weights:","medium-light","skin","tone"]}, + {"emoji":"🏋🏽‍♂️","name":"man lifting weights: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","lifting","weights:","medium","skin","tone"]}, + {"emoji":"🏋🏾‍♂️","name":"man lifting weights: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","lifting","weights:","medium-dark","skin","tone"]}, + {"emoji":"🏋🏿‍♂️","name":"man lifting weights: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","lifting","weights:","dark","skin","tone"]}, + {"emoji":"🏋️‍♀️","name":"woman lifting weights","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman","lifting","weights"]}, + {"emoji":"🏋🏻‍♀️","name":"woman lifting weights: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","lifting","weights:","light","skin","tone"]}, + {"emoji":"🏋🏼‍♀️","name":"woman lifting weights: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","lifting","weights:","medium-light","skin","tone"]}, + {"emoji":"🏋🏽‍♀️","name":"woman lifting weights: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","lifting","weights:","medium","skin","tone"]}, + {"emoji":"🏋🏾‍♀️","name":"woman lifting weights: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","lifting","weights:","medium-dark","skin","tone"]}, + {"emoji":"🏋🏿‍♀️","name":"woman lifting weights: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","lifting","weights:","dark","skin","tone"]}, + {"emoji":"🚴","name":"person biking","category":"Smileys & People","version":"1.0","skinTone":false,"keywords":["person","biking","bicycle","bicyclist","bike","cycle","cyclist","riding","sport"]}, + {"emoji":"🚴🏻","name":"person biking: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","biking:","light","skin","tone"]}, + {"emoji":"🚴🏼","name":"person biking: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","biking:","medium-light","skin","tone"]}, + {"emoji":"🚴🏽","name":"person biking: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","biking:","medium","skin","tone"]}, + {"emoji":"🚴🏾","name":"person biking: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","biking:","medium-dark","skin","tone"]}, + {"emoji":"🚴🏿","name":"person biking: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","biking:","dark","skin","tone"]}, + {"emoji":"🚴‍♂️","name":"man biking","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man","biking"]}, + {"emoji":"🚴🏻‍♂️","name":"man biking: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","biking:","light","skin","tone"]}, + {"emoji":"🚴🏼‍♂️","name":"man biking: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","biking:","medium-light","skin","tone"]}, + {"emoji":"🚴🏽‍♂️","name":"man biking: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","biking:","medium","skin","tone"]}, + {"emoji":"🚴🏾‍♂️","name":"man biking: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","biking:","medium-dark","skin","tone"]}, + {"emoji":"🚴🏿‍♂️","name":"man biking: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","biking:","dark","skin","tone"]}, + {"emoji":"🚴‍♀️","name":"woman biking","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman","biking"]}, + {"emoji":"🚴🏻‍♀️","name":"woman biking: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","biking:","light","skin","tone"]}, + {"emoji":"🚴🏼‍♀️","name":"woman biking: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","biking:","medium-light","skin","tone"]}, + {"emoji":"🚴🏽‍♀️","name":"woman biking: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","biking:","medium","skin","tone"]}, + {"emoji":"🚴🏾‍♀️","name":"woman biking: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","biking:","medium-dark","skin","tone"]}, + {"emoji":"🚴🏿‍♀️","name":"woman biking: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","biking:","dark","skin","tone"]}, + {"emoji":"🚵","name":"person mountain biking","category":"Smileys & People","version":"1.0","skinTone":false,"keywords":["person","mountain","biking","bicycle","bicyclist","bike","cycle","cyclist","riding","sport"]}, + {"emoji":"🚵🏻","name":"person mountain biking: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","mountain","biking:","light","skin","tone"]}, + {"emoji":"🚵🏼","name":"person mountain biking: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","mountain","biking:","medium-light","skin","tone"]}, + {"emoji":"🚵🏽","name":"person mountain biking: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","mountain","biking:","medium","skin","tone"]}, + {"emoji":"🚵🏾","name":"person mountain biking: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","mountain","biking:","medium-dark","skin","tone"]}, + {"emoji":"🚵🏿","name":"person mountain biking: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","mountain","biking:","dark","skin","tone"]}, + {"emoji":"🚵‍♂️","name":"man mountain biking","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man","mountain","biking"]}, + {"emoji":"🚵🏻‍♂️","name":"man mountain biking: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","mountain","biking:","light","skin","tone"]}, + {"emoji":"🚵🏼‍♂️","name":"man mountain biking: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","mountain","biking:","medium-light","skin","tone"]}, + {"emoji":"🚵🏽‍♂️","name":"man mountain biking: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","mountain","biking:","medium","skin","tone"]}, + {"emoji":"🚵🏾‍♂️","name":"man mountain biking: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","mountain","biking:","medium-dark","skin","tone"]}, + {"emoji":"🚵🏿‍♂️","name":"man mountain biking: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","mountain","biking:","dark","skin","tone"]}, + {"emoji":"🚵‍♀️","name":"woman mountain biking","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman","mountain","biking"]}, + {"emoji":"🚵🏻‍♀️","name":"woman mountain biking: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","mountain","biking:","light","skin","tone"]}, + {"emoji":"🚵🏼‍♀️","name":"woman mountain biking: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","mountain","biking:","medium-light","skin","tone"]}, + {"emoji":"🚵🏽‍♀️","name":"woman mountain biking: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","mountain","biking:","medium","skin","tone"]}, + {"emoji":"🚵🏾‍♀️","name":"woman mountain biking: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","mountain","biking:","medium-dark","skin","tone"]}, + {"emoji":"🚵🏿‍♀️","name":"woman mountain biking: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","mountain","biking:","dark","skin","tone"]}, + {"emoji":"🤸","name":"person cartwheeling","category":"Smileys & People","version":"3.0","skinTone":false,"keywords":["person","cartwheeling","active","cartwheel","excited","flip","gymnastics","happy","somersault"]}, + {"emoji":"🤸🏻","name":"person cartwheeling: light skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["person","cartwheeling:","light","skin","tone"]}, + {"emoji":"🤸🏼","name":"person cartwheeling: medium-light skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["person","cartwheeling:","medium-light","skin","tone"]}, + {"emoji":"🤸🏽","name":"person cartwheeling: medium skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["person","cartwheeling:","medium","skin","tone"]}, + {"emoji":"🤸🏾","name":"person cartwheeling: medium-dark skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["person","cartwheeling:","medium-dark","skin","tone"]}, + {"emoji":"🤸🏿","name":"person cartwheeling: dark skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["person","cartwheeling:","dark","skin","tone"]}, + {"emoji":"🤸‍♂️","name":"man cartwheeling","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man","cartwheeling"]}, + {"emoji":"🤸🏻‍♂️","name":"man cartwheeling: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","cartwheeling:","light","skin","tone"]}, + {"emoji":"🤸🏼‍♂️","name":"man cartwheeling: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","cartwheeling:","medium-light","skin","tone"]}, + {"emoji":"🤸🏽‍♂️","name":"man cartwheeling: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","cartwheeling:","medium","skin","tone"]}, + {"emoji":"🤸🏾‍♂️","name":"man cartwheeling: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","cartwheeling:","medium-dark","skin","tone"]}, + {"emoji":"🤸🏿‍♂️","name":"man cartwheeling: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","cartwheeling:","dark","skin","tone"]}, + {"emoji":"🤸‍♀️","name":"woman cartwheeling","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman","cartwheeling"]}, + {"emoji":"🤸🏻‍♀️","name":"woman cartwheeling: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","cartwheeling:","light","skin","tone"]}, + {"emoji":"🤸🏼‍♀️","name":"woman cartwheeling: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","cartwheeling:","medium-light","skin","tone"]}, + {"emoji":"🤸🏽‍♀️","name":"woman cartwheeling: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","cartwheeling:","medium","skin","tone"]}, + {"emoji":"🤸🏾‍♀️","name":"woman cartwheeling: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","cartwheeling:","medium-dark","skin","tone"]}, + {"emoji":"🤸🏿‍♀️","name":"woman cartwheeling: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","cartwheeling:","dark","skin","tone"]}, + {"emoji":"🤼","name":"people wrestling","category":"Smileys & People","version":"3.0","skinTone":false,"keywords":["people","wrestling","combat","duel","grapple","ring","tournament","wrestle"]}, + {"emoji":"🤼🏻","name":"people wrestling: light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","wrestling:","light","skin","tone"]}, + {"emoji":"🤼🏼","name":"people wrestling: medium-light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","wrestling:","medium-light","skin","tone"]}, + {"emoji":"🤼🏽","name":"people wrestling: medium skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","wrestling:","medium","skin","tone"]}, + {"emoji":"🤼🏾","name":"people wrestling: medium-dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","wrestling:","medium-dark","skin","tone"]}, + {"emoji":"🤼🏿","name":"people wrestling: dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","wrestling:","dark","skin","tone"]}, + {"emoji":"🤼‍♂️","name":"men wrestling","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["men","wrestling"]}, + {"emoji":"🤼🏻‍♂️","name":"men wrestling: light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","wrestling:","light","skin","tone"]}, + {"emoji":"🤼🏼‍♂️","name":"men wrestling: medium-light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","wrestling:","medium-light","skin","tone"]}, + {"emoji":"🤼🏽‍♂️","name":"men wrestling: medium skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","wrestling:","medium","skin","tone"]}, + {"emoji":"🤼🏾‍♂️","name":"men wrestling: medium-dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","wrestling:","medium-dark","skin","tone"]}, + {"emoji":"🤼🏿‍♂️","name":"men wrestling: dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","wrestling:","dark","skin","tone"]}, + {"emoji":"🤼‍♀️","name":"women wrestling","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["women","wrestling"]}, + {"emoji":"🤼🏻‍♀️","name":"women wrestling: light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","wrestling:","light","skin","tone"]}, + {"emoji":"🤼🏼‍♀️","name":"women wrestling: medium-light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","wrestling:","medium-light","skin","tone"]}, + {"emoji":"🤼🏽‍♀️","name":"women wrestling: medium skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","wrestling:","medium","skin","tone"]}, + {"emoji":"🤼🏾‍♀️","name":"women wrestling: medium-dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","wrestling:","medium-dark","skin","tone"]}, + {"emoji":"🤼🏿‍♀️","name":"women wrestling: dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","wrestling:","dark","skin","tone"]}, + {"emoji":"🧑🏻‍🫯‍🧑🏼","name":"people wrestling: light skin tone, medium-light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","wrestling:","light","skin","tone,","medium-light","tone"]}, + {"emoji":"🧑🏻‍🫯‍🧑🏽","name":"people wrestling: light skin tone, medium skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","wrestling:","light","skin","tone,","medium","tone"]}, + {"emoji":"🧑🏻‍🫯‍🧑🏾","name":"people wrestling: light skin tone, medium-dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","wrestling:","light","skin","tone,","medium-dark","tone"]}, + {"emoji":"🧑🏻‍🫯‍🧑🏿","name":"people wrestling: light skin tone, dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","wrestling:","light","skin","tone,","dark","tone"]}, + {"emoji":"🧑🏼‍🫯‍🧑🏻","name":"people wrestling: medium-light skin tone, light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","wrestling:","medium-light","skin","tone,","light","tone"]}, + {"emoji":"🧑🏼‍🫯‍🧑🏽","name":"people wrestling: medium-light skin tone, medium skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","wrestling:","medium-light","skin","tone,","medium","tone"]}, + {"emoji":"🧑🏼‍🫯‍🧑🏾","name":"people wrestling: medium-light skin tone, medium-dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","wrestling:","medium-light","skin","tone,","medium-dark","tone"]}, + {"emoji":"🧑🏼‍🫯‍🧑🏿","name":"people wrestling: medium-light skin tone, dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","wrestling:","medium-light","skin","tone,","dark","tone"]}, + {"emoji":"🧑🏽‍🫯‍🧑🏻","name":"people wrestling: medium skin tone, light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","wrestling:","medium","skin","tone,","light","tone"]}, + {"emoji":"🧑🏽‍🫯‍🧑🏼","name":"people wrestling: medium skin tone, medium-light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","wrestling:","medium","skin","tone,","medium-light","tone"]}, + {"emoji":"🧑🏽‍🫯‍🧑🏾","name":"people wrestling: medium skin tone, medium-dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","wrestling:","medium","skin","tone,","medium-dark","tone"]}, + {"emoji":"🧑🏽‍🫯‍🧑🏿","name":"people wrestling: medium skin tone, dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","wrestling:","medium","skin","tone,","dark","tone"]}, + {"emoji":"🧑🏾‍🫯‍🧑🏻","name":"people wrestling: medium-dark skin tone, light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","wrestling:","medium-dark","skin","tone,","light","tone"]}, + {"emoji":"🧑🏾‍🫯‍🧑🏼","name":"people wrestling: medium-dark skin tone, medium-light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","wrestling:","medium-dark","skin","tone,","medium-light","tone"]}, + {"emoji":"🧑🏾‍🫯‍🧑🏽","name":"people wrestling: medium-dark skin tone, medium skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","wrestling:","medium-dark","skin","tone,","medium","tone"]}, + {"emoji":"🧑🏾‍🫯‍🧑🏿","name":"people wrestling: medium-dark skin tone, dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","wrestling:","medium-dark","skin","tone,","dark","tone"]}, + {"emoji":"🧑🏿‍🫯‍🧑🏻","name":"people wrestling: dark skin tone, light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","wrestling:","dark","skin","tone,","light","tone"]}, + {"emoji":"🧑🏿‍🫯‍🧑🏼","name":"people wrestling: dark skin tone, medium-light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","wrestling:","dark","skin","tone,","medium-light","tone"]}, + {"emoji":"🧑🏿‍🫯‍🧑🏽","name":"people wrestling: dark skin tone, medium skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","wrestling:","dark","skin","tone,","medium","tone"]}, + {"emoji":"🧑🏿‍🫯‍🧑🏾","name":"people wrestling: dark skin tone, medium-dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["people","wrestling:","dark","skin","tone,","medium-dark","tone"]}, + {"emoji":"👨🏻‍🫯‍👨🏼","name":"men wrestling: light skin tone, medium-light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","wrestling:","light","skin","tone,","medium-light","tone"]}, + {"emoji":"👨🏻‍🫯‍👨🏽","name":"men wrestling: light skin tone, medium skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","wrestling:","light","skin","tone,","medium","tone"]}, + {"emoji":"👨🏻‍🫯‍👨🏾","name":"men wrestling: light skin tone, medium-dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","wrestling:","light","skin","tone,","medium-dark","tone"]}, + {"emoji":"👨🏻‍🫯‍👨🏿","name":"men wrestling: light skin tone, dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","wrestling:","light","skin","tone,","dark","tone"]}, + {"emoji":"👨🏼‍🫯‍👨🏻","name":"men wrestling: medium-light skin tone, light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","wrestling:","medium-light","skin","tone,","light","tone"]}, + {"emoji":"👨🏼‍🫯‍👨🏽","name":"men wrestling: medium-light skin tone, medium skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","wrestling:","medium-light","skin","tone,","medium","tone"]}, + {"emoji":"👨🏼‍🫯‍👨🏾","name":"men wrestling: medium-light skin tone, medium-dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","wrestling:","medium-light","skin","tone,","medium-dark","tone"]}, + {"emoji":"👨🏼‍🫯‍👨🏿","name":"men wrestling: medium-light skin tone, dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","wrestling:","medium-light","skin","tone,","dark","tone"]}, + {"emoji":"👨🏽‍🫯‍👨🏻","name":"men wrestling: medium skin tone, light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","wrestling:","medium","skin","tone,","light","tone"]}, + {"emoji":"👨🏽‍🫯‍👨🏼","name":"men wrestling: medium skin tone, medium-light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","wrestling:","medium","skin","tone,","medium-light","tone"]}, + {"emoji":"👨🏽‍🫯‍👨🏾","name":"men wrestling: medium skin tone, medium-dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","wrestling:","medium","skin","tone,","medium-dark","tone"]}, + {"emoji":"👨🏽‍🫯‍👨🏿","name":"men wrestling: medium skin tone, dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","wrestling:","medium","skin","tone,","dark","tone"]}, + {"emoji":"👨🏾‍🫯‍👨🏻","name":"men wrestling: medium-dark skin tone, light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","wrestling:","medium-dark","skin","tone,","light","tone"]}, + {"emoji":"👨🏾‍🫯‍👨🏼","name":"men wrestling: medium-dark skin tone, medium-light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","wrestling:","medium-dark","skin","tone,","medium-light","tone"]}, + {"emoji":"👨🏾‍🫯‍👨🏽","name":"men wrestling: medium-dark skin tone, medium skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","wrestling:","medium-dark","skin","tone,","medium","tone"]}, + {"emoji":"👨🏾‍🫯‍👨🏿","name":"men wrestling: medium-dark skin tone, dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","wrestling:","medium-dark","skin","tone,","dark","tone"]}, + {"emoji":"👨🏿‍🫯‍👨🏻","name":"men wrestling: dark skin tone, light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","wrestling:","dark","skin","tone,","light","tone"]}, + {"emoji":"👨🏿‍🫯‍👨🏼","name":"men wrestling: dark skin tone, medium-light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","wrestling:","dark","skin","tone,","medium-light","tone"]}, + {"emoji":"👨🏿‍🫯‍👨🏽","name":"men wrestling: dark skin tone, medium skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","wrestling:","dark","skin","tone,","medium","tone"]}, + {"emoji":"👨🏿‍🫯‍👨🏾","name":"men wrestling: dark skin tone, medium-dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["men","wrestling:","dark","skin","tone,","medium-dark","tone"]}, + {"emoji":"👩🏻‍🫯‍👩🏼","name":"women wrestling: light skin tone, medium-light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","wrestling:","light","skin","tone,","medium-light","tone"]}, + {"emoji":"👩🏻‍🫯‍👩🏽","name":"women wrestling: light skin tone, medium skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","wrestling:","light","skin","tone,","medium","tone"]}, + {"emoji":"👩🏻‍🫯‍👩🏾","name":"women wrestling: light skin tone, medium-dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","wrestling:","light","skin","tone,","medium-dark","tone"]}, + {"emoji":"👩🏻‍🫯‍👩🏿","name":"women wrestling: light skin tone, dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","wrestling:","light","skin","tone,","dark","tone"]}, + {"emoji":"👩🏼‍🫯‍👩🏻","name":"women wrestling: medium-light skin tone, light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","wrestling:","medium-light","skin","tone,","light","tone"]}, + {"emoji":"👩🏼‍🫯‍👩🏽","name":"women wrestling: medium-light skin tone, medium skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","wrestling:","medium-light","skin","tone,","medium","tone"]}, + {"emoji":"👩🏼‍🫯‍👩🏾","name":"women wrestling: medium-light skin tone, medium-dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","wrestling:","medium-light","skin","tone,","medium-dark","tone"]}, + {"emoji":"👩🏼‍🫯‍👩🏿","name":"women wrestling: medium-light skin tone, dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","wrestling:","medium-light","skin","tone,","dark","tone"]}, + {"emoji":"👩🏽‍🫯‍👩🏻","name":"women wrestling: medium skin tone, light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","wrestling:","medium","skin","tone,","light","tone"]}, + {"emoji":"👩🏽‍🫯‍👩🏼","name":"women wrestling: medium skin tone, medium-light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","wrestling:","medium","skin","tone,","medium-light","tone"]}, + {"emoji":"👩🏽‍🫯‍👩🏾","name":"women wrestling: medium skin tone, medium-dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","wrestling:","medium","skin","tone,","medium-dark","tone"]}, + {"emoji":"👩🏽‍🫯‍👩🏿","name":"women wrestling: medium skin tone, dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","wrestling:","medium","skin","tone,","dark","tone"]}, + {"emoji":"👩🏾‍🫯‍👩🏻","name":"women wrestling: medium-dark skin tone, light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","wrestling:","medium-dark","skin","tone,","light","tone"]}, + {"emoji":"👩🏾‍🫯‍👩🏼","name":"women wrestling: medium-dark skin tone, medium-light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","wrestling:","medium-dark","skin","tone,","medium-light","tone"]}, + {"emoji":"👩🏾‍🫯‍👩🏽","name":"women wrestling: medium-dark skin tone, medium skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","wrestling:","medium-dark","skin","tone,","medium","tone"]}, + {"emoji":"👩🏾‍🫯‍👩🏿","name":"women wrestling: medium-dark skin tone, dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","wrestling:","medium-dark","skin","tone,","dark","tone"]}, + {"emoji":"👩🏿‍🫯‍👩🏻","name":"women wrestling: dark skin tone, light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","wrestling:","dark","skin","tone,","light","tone"]}, + {"emoji":"👩🏿‍🫯‍👩🏼","name":"women wrestling: dark skin tone, medium-light skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","wrestling:","dark","skin","tone,","medium-light","tone"]}, + {"emoji":"👩🏿‍🫯‍👩🏽","name":"women wrestling: dark skin tone, medium skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","wrestling:","dark","skin","tone,","medium","tone"]}, + {"emoji":"👩🏿‍🫯‍👩🏾","name":"women wrestling: dark skin tone, medium-dark skin tone","category":"Smileys & People","version":"17.0","skinTone":true,"keywords":["women","wrestling:","dark","skin","tone,","medium-dark","tone"]}, + {"emoji":"🤽","name":"person playing water polo","category":"Smileys & People","version":"3.0","skinTone":false,"keywords":["person","playing","water","polo","sport","swimming","waterpolo"]}, + {"emoji":"🤽🏻","name":"person playing water polo: light skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["person","playing","water","polo:","light","skin","tone"]}, + {"emoji":"🤽🏼","name":"person playing water polo: medium-light skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["person","playing","water","polo:","medium-light","skin","tone"]}, + {"emoji":"🤽🏽","name":"person playing water polo: medium skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["person","playing","water","polo:","medium","skin","tone"]}, + {"emoji":"🤽🏾","name":"person playing water polo: medium-dark skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["person","playing","water","polo:","medium-dark","skin","tone"]}, + {"emoji":"🤽🏿","name":"person playing water polo: dark skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["person","playing","water","polo:","dark","skin","tone"]}, + {"emoji":"🤽‍♂️","name":"man playing water polo","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man","playing","water","polo"]}, + {"emoji":"🤽🏻‍♂️","name":"man playing water polo: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","playing","water","polo:","light","skin","tone"]}, + {"emoji":"🤽🏼‍♂️","name":"man playing water polo: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","playing","water","polo:","medium-light","skin","tone"]}, + {"emoji":"🤽🏽‍♂️","name":"man playing water polo: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","playing","water","polo:","medium","skin","tone"]}, + {"emoji":"🤽🏾‍♂️","name":"man playing water polo: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","playing","water","polo:","medium-dark","skin","tone"]}, + {"emoji":"🤽🏿‍♂️","name":"man playing water polo: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","playing","water","polo:","dark","skin","tone"]}, + {"emoji":"🤽‍♀️","name":"woman playing water polo","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman","playing","water","polo"]}, + {"emoji":"🤽🏻‍♀️","name":"woman playing water polo: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","playing","water","polo:","light","skin","tone"]}, + {"emoji":"🤽🏼‍♀️","name":"woman playing water polo: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","playing","water","polo:","medium-light","skin","tone"]}, + {"emoji":"🤽🏽‍♀️","name":"woman playing water polo: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","playing","water","polo:","medium","skin","tone"]}, + {"emoji":"🤽🏾‍♀️","name":"woman playing water polo: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","playing","water","polo:","medium-dark","skin","tone"]}, + {"emoji":"🤽🏿‍♀️","name":"woman playing water polo: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","playing","water","polo:","dark","skin","tone"]}, + {"emoji":"🤾","name":"person playing handball","category":"Smileys & People","version":"3.0","skinTone":false,"keywords":["person","playing","handball","athletics","ball","catch","chuck","hurl","lob","pitch","sport","throw","toss"]}, + {"emoji":"🤾🏻","name":"person playing handball: light skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["person","playing","handball:","light","skin","tone"]}, + {"emoji":"🤾🏼","name":"person playing handball: medium-light skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["person","playing","handball:","medium-light","skin","tone"]}, + {"emoji":"🤾🏽","name":"person playing handball: medium skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["person","playing","handball:","medium","skin","tone"]}, + {"emoji":"🤾🏾","name":"person playing handball: medium-dark skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["person","playing","handball:","medium-dark","skin","tone"]}, + {"emoji":"🤾🏿","name":"person playing handball: dark skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["person","playing","handball:","dark","skin","tone"]}, + {"emoji":"🤾‍♂️","name":"man playing handball","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man","playing","handball"]}, + {"emoji":"🤾🏻‍♂️","name":"man playing handball: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","playing","handball:","light","skin","tone"]}, + {"emoji":"🤾🏼‍♂️","name":"man playing handball: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","playing","handball:","medium-light","skin","tone"]}, + {"emoji":"🤾🏽‍♂️","name":"man playing handball: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","playing","handball:","medium","skin","tone"]}, + {"emoji":"🤾🏾‍♂️","name":"man playing handball: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","playing","handball:","medium-dark","skin","tone"]}, + {"emoji":"🤾🏿‍♂️","name":"man playing handball: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","playing","handball:","dark","skin","tone"]}, + {"emoji":"🤾‍♀️","name":"woman playing handball","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman","playing","handball"]}, + {"emoji":"🤾🏻‍♀️","name":"woman playing handball: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","playing","handball:","light","skin","tone"]}, + {"emoji":"🤾🏼‍♀️","name":"woman playing handball: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","playing","handball:","medium-light","skin","tone"]}, + {"emoji":"🤾🏽‍♀️","name":"woman playing handball: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","playing","handball:","medium","skin","tone"]}, + {"emoji":"🤾🏾‍♀️","name":"woman playing handball: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","playing","handball:","medium-dark","skin","tone"]}, + {"emoji":"🤾🏿‍♀️","name":"woman playing handball: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","playing","handball:","dark","skin","tone"]}, + {"emoji":"🤹","name":"person juggling","category":"Smileys & People","version":"3.0","skinTone":false,"keywords":["person","juggling","act","balance","balancing","handle","juggle","manage","multitask","skill"]}, + {"emoji":"🤹🏻","name":"person juggling: light skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["person","juggling:","light","skin","tone"]}, + {"emoji":"🤹🏼","name":"person juggling: medium-light skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["person","juggling:","medium-light","skin","tone"]}, + {"emoji":"🤹🏽","name":"person juggling: medium skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["person","juggling:","medium","skin","tone"]}, + {"emoji":"🤹🏾","name":"person juggling: medium-dark skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["person","juggling:","medium-dark","skin","tone"]}, + {"emoji":"🤹🏿","name":"person juggling: dark skin tone","category":"Smileys & People","version":"3.0","skinTone":true,"keywords":["person","juggling:","dark","skin","tone"]}, + {"emoji":"🤹‍♂️","name":"man juggling","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["man","juggling"]}, + {"emoji":"🤹🏻‍♂️","name":"man juggling: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","juggling:","light","skin","tone"]}, + {"emoji":"🤹🏼‍♂️","name":"man juggling: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","juggling:","medium-light","skin","tone"]}, + {"emoji":"🤹🏽‍♂️","name":"man juggling: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","juggling:","medium","skin","tone"]}, + {"emoji":"🤹🏾‍♂️","name":"man juggling: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","juggling:","medium-dark","skin","tone"]}, + {"emoji":"🤹🏿‍♂️","name":"man juggling: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["man","juggling:","dark","skin","tone"]}, + {"emoji":"🤹‍♀️","name":"woman juggling","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["woman","juggling"]}, + {"emoji":"🤹🏻‍♀️","name":"woman juggling: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","juggling:","light","skin","tone"]}, + {"emoji":"🤹🏼‍♀️","name":"woman juggling: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","juggling:","medium-light","skin","tone"]}, + {"emoji":"🤹🏽‍♀️","name":"woman juggling: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","juggling:","medium","skin","tone"]}, + {"emoji":"🤹🏾‍♀️","name":"woman juggling: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","juggling:","medium-dark","skin","tone"]}, + {"emoji":"🤹🏿‍♀️","name":"woman juggling: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["woman","juggling:","dark","skin","tone"]}, + {"emoji":"🧘","name":"person in lotus position","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["person","in","lotus","position","cross","legged","legs","meditation","peace","relax","serenity","yoga","yogi","zen"]}, + {"emoji":"🧘🏻","name":"person in lotus position: light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["person","in","lotus","position:","light","skin","tone"]}, + {"emoji":"🧘🏼","name":"person in lotus position: medium-light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["person","in","lotus","position:","medium-light","skin","tone"]}, + {"emoji":"🧘🏽","name":"person in lotus position: medium skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["person","in","lotus","position:","medium","skin","tone"]}, + {"emoji":"🧘🏾","name":"person in lotus position: medium-dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["person","in","lotus","position:","medium-dark","skin","tone"]}, + {"emoji":"🧘🏿","name":"person in lotus position: dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["person","in","lotus","position:","dark","skin","tone"]}, + {"emoji":"🧘‍♂️","name":"man in lotus position","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["man","in","lotus","position"]}, + {"emoji":"🧘🏻‍♂️","name":"man in lotus position: light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["man","in","lotus","position:","light","skin","tone"]}, + {"emoji":"🧘🏼‍♂️","name":"man in lotus position: medium-light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["man","in","lotus","position:","medium-light","skin","tone"]}, + {"emoji":"🧘🏽‍♂️","name":"man in lotus position: medium skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["man","in","lotus","position:","medium","skin","tone"]}, + {"emoji":"🧘🏾‍♂️","name":"man in lotus position: medium-dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["man","in","lotus","position:","medium-dark","skin","tone"]}, + {"emoji":"🧘🏿‍♂️","name":"man in lotus position: dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["man","in","lotus","position:","dark","skin","tone"]}, + {"emoji":"🧘‍♀️","name":"woman in lotus position","category":"Smileys & People","version":"5.0","skinTone":false,"keywords":["woman","in","lotus","position"]}, + {"emoji":"🧘🏻‍♀️","name":"woman in lotus position: light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["woman","in","lotus","position:","light","skin","tone"]}, + {"emoji":"🧘🏼‍♀️","name":"woman in lotus position: medium-light skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["woman","in","lotus","position:","medium-light","skin","tone"]}, + {"emoji":"🧘🏽‍♀️","name":"woman in lotus position: medium skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["woman","in","lotus","position:","medium","skin","tone"]}, + {"emoji":"🧘🏾‍♀️","name":"woman in lotus position: medium-dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["woman","in","lotus","position:","medium-dark","skin","tone"]}, + {"emoji":"🧘🏿‍♀️","name":"woman in lotus position: dark skin tone","category":"Smileys & People","version":"5.0","skinTone":true,"keywords":["woman","in","lotus","position:","dark","skin","tone"]}, + {"emoji":"🛀","name":"person taking bath","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["person","taking","bath","bathtub","tub"]}, + {"emoji":"🛀🏻","name":"person taking bath: light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","taking","bath:","light","skin","tone"]}, + {"emoji":"🛀🏼","name":"person taking bath: medium-light skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","taking","bath:","medium-light","skin","tone"]}, + {"emoji":"🛀🏽","name":"person taking bath: medium skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","taking","bath:","medium","skin","tone"]}, + {"emoji":"🛀🏾","name":"person taking bath: medium-dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","taking","bath:","medium-dark","skin","tone"]}, + {"emoji":"🛀🏿","name":"person taking bath: dark skin tone","category":"Smileys & People","version":"1.0","skinTone":true,"keywords":["person","taking","bath:","dark","skin","tone"]}, + {"emoji":"🛌","name":"person in bed","category":"Smileys & People","version":"1.0","skinTone":false,"keywords":["person","in","bed","bedtime","good","goodnight","hotel","nap","night","sleep","tired","zzz"]}, + {"emoji":"🛌🏻","name":"person in bed: light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["person","in","bed:","light","skin","tone"]}, + {"emoji":"🛌🏼","name":"person in bed: medium-light skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["person","in","bed:","medium-light","skin","tone"]}, + {"emoji":"🛌🏽","name":"person in bed: medium skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["person","in","bed:","medium","skin","tone"]}, + {"emoji":"🛌🏾","name":"person in bed: medium-dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["person","in","bed:","medium-dark","skin","tone"]}, + {"emoji":"🛌🏿","name":"person in bed: dark skin tone","category":"Smileys & People","version":"4.0","skinTone":true,"keywords":["person","in","bed:","dark","skin","tone"]}, + {"emoji":"🧑‍🤝‍🧑","name":"people holding hands","category":"Smileys & People","version":"12.0","skinTone":false,"keywords":["people","holding","hands","bae","bestie","bff","couple","dating","flirt","friends","hand","hold","twins"]}, + {"emoji":"🧑🏻‍🤝‍🧑🏻","name":"people holding hands: light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["people","holding","hands:","light","skin","tone"]}, + {"emoji":"🧑🏻‍🤝‍🧑🏼","name":"people holding hands: light skin tone, medium-light skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["people","holding","hands:","light","skin","tone,","medium-light","tone"]}, + {"emoji":"🧑🏻‍🤝‍🧑🏽","name":"people holding hands: light skin tone, medium skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["people","holding","hands:","light","skin","tone,","medium","tone"]}, + {"emoji":"🧑🏻‍🤝‍🧑🏾","name":"people holding hands: light skin tone, medium-dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["people","holding","hands:","light","skin","tone,","medium-dark","tone"]}, + {"emoji":"🧑🏻‍🤝‍🧑🏿","name":"people holding hands: light skin tone, dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["people","holding","hands:","light","skin","tone,","dark","tone"]}, + {"emoji":"🧑🏼‍🤝‍🧑🏻","name":"people holding hands: medium-light skin tone, light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["people","holding","hands:","medium-light","skin","tone,","light","tone"]}, + {"emoji":"🧑🏼‍🤝‍🧑🏼","name":"people holding hands: medium-light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["people","holding","hands:","medium-light","skin","tone"]}, + {"emoji":"🧑🏼‍🤝‍🧑🏽","name":"people holding hands: medium-light skin tone, medium skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["people","holding","hands:","medium-light","skin","tone,","medium","tone"]}, + {"emoji":"🧑🏼‍🤝‍🧑🏾","name":"people holding hands: medium-light skin tone, medium-dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["people","holding","hands:","medium-light","skin","tone,","medium-dark","tone"]}, + {"emoji":"🧑🏼‍🤝‍🧑🏿","name":"people holding hands: medium-light skin tone, dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["people","holding","hands:","medium-light","skin","tone,","dark","tone"]}, + {"emoji":"🧑🏽‍🤝‍🧑🏻","name":"people holding hands: medium skin tone, light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["people","holding","hands:","medium","skin","tone,","light","tone"]}, + {"emoji":"🧑🏽‍🤝‍🧑🏼","name":"people holding hands: medium skin tone, medium-light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["people","holding","hands:","medium","skin","tone,","medium-light","tone"]}, + {"emoji":"🧑🏽‍🤝‍🧑🏽","name":"people holding hands: medium skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["people","holding","hands:","medium","skin","tone"]}, + {"emoji":"🧑🏽‍🤝‍🧑🏾","name":"people holding hands: medium skin tone, medium-dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["people","holding","hands:","medium","skin","tone,","medium-dark","tone"]}, + {"emoji":"🧑🏽‍🤝‍🧑🏿","name":"people holding hands: medium skin tone, dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["people","holding","hands:","medium","skin","tone,","dark","tone"]}, + {"emoji":"🧑🏾‍🤝‍🧑🏻","name":"people holding hands: medium-dark skin tone, light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["people","holding","hands:","medium-dark","skin","tone,","light","tone"]}, + {"emoji":"🧑🏾‍🤝‍🧑🏼","name":"people holding hands: medium-dark skin tone, medium-light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["people","holding","hands:","medium-dark","skin","tone,","medium-light","tone"]}, + {"emoji":"🧑🏾‍🤝‍🧑🏽","name":"people holding hands: medium-dark skin tone, medium skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["people","holding","hands:","medium-dark","skin","tone,","medium","tone"]}, + {"emoji":"🧑🏾‍🤝‍🧑🏾","name":"people holding hands: medium-dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["people","holding","hands:","medium-dark","skin","tone"]}, + {"emoji":"🧑🏾‍🤝‍🧑🏿","name":"people holding hands: medium-dark skin tone, dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["people","holding","hands:","medium-dark","skin","tone,","dark","tone"]}, + {"emoji":"🧑🏿‍🤝‍🧑🏻","name":"people holding hands: dark skin tone, light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["people","holding","hands:","dark","skin","tone,","light","tone"]}, + {"emoji":"🧑🏿‍🤝‍🧑🏼","name":"people holding hands: dark skin tone, medium-light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["people","holding","hands:","dark","skin","tone,","medium-light","tone"]}, + {"emoji":"🧑🏿‍🤝‍🧑🏽","name":"people holding hands: dark skin tone, medium skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["people","holding","hands:","dark","skin","tone,","medium","tone"]}, + {"emoji":"🧑🏿‍🤝‍🧑🏾","name":"people holding hands: dark skin tone, medium-dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["people","holding","hands:","dark","skin","tone,","medium-dark","tone"]}, + {"emoji":"🧑🏿‍🤝‍🧑🏿","name":"people holding hands: dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["people","holding","hands:","dark","skin","tone"]}, + {"emoji":"👭","name":"women holding hands","category":"Smileys & People","version":"1.0","skinTone":false,"keywords":["women","holding","hands","bae","bestie","bff","couple","dating","flirt","friends","girls","hand","hold","sisters","twins"]}, + {"emoji":"👭🏻","name":"women holding hands: light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["women","holding","hands:","light","skin","tone"]}, + {"emoji":"👩🏻‍🤝‍👩🏼","name":"women holding hands: light skin tone, medium-light skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["women","holding","hands:","light","skin","tone,","medium-light","tone"]}, + {"emoji":"👩🏻‍🤝‍👩🏽","name":"women holding hands: light skin tone, medium skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["women","holding","hands:","light","skin","tone,","medium","tone"]}, + {"emoji":"👩🏻‍🤝‍👩🏾","name":"women holding hands: light skin tone, medium-dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["women","holding","hands:","light","skin","tone,","medium-dark","tone"]}, + {"emoji":"👩🏻‍🤝‍👩🏿","name":"women holding hands: light skin tone, dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["women","holding","hands:","light","skin","tone,","dark","tone"]}, + {"emoji":"👩🏼‍🤝‍👩🏻","name":"women holding hands: medium-light skin tone, light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["women","holding","hands:","medium-light","skin","tone,","light","tone"]}, + {"emoji":"👭🏼","name":"women holding hands: medium-light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["women","holding","hands:","medium-light","skin","tone"]}, + {"emoji":"👩🏼‍🤝‍👩🏽","name":"women holding hands: medium-light skin tone, medium skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["women","holding","hands:","medium-light","skin","tone,","medium","tone"]}, + {"emoji":"👩🏼‍🤝‍👩🏾","name":"women holding hands: medium-light skin tone, medium-dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["women","holding","hands:","medium-light","skin","tone,","medium-dark","tone"]}, + {"emoji":"👩🏼‍🤝‍👩🏿","name":"women holding hands: medium-light skin tone, dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["women","holding","hands:","medium-light","skin","tone,","dark","tone"]}, + {"emoji":"👩🏽‍🤝‍👩🏻","name":"women holding hands: medium skin tone, light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["women","holding","hands:","medium","skin","tone,","light","tone"]}, + {"emoji":"👩🏽‍🤝‍👩🏼","name":"women holding hands: medium skin tone, medium-light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["women","holding","hands:","medium","skin","tone,","medium-light","tone"]}, + {"emoji":"👭🏽","name":"women holding hands: medium skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["women","holding","hands:","medium","skin","tone"]}, + {"emoji":"👩🏽‍🤝‍👩🏾","name":"women holding hands: medium skin tone, medium-dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["women","holding","hands:","medium","skin","tone,","medium-dark","tone"]}, + {"emoji":"👩🏽‍🤝‍👩🏿","name":"women holding hands: medium skin tone, dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["women","holding","hands:","medium","skin","tone,","dark","tone"]}, + {"emoji":"👩🏾‍🤝‍👩🏻","name":"women holding hands: medium-dark skin tone, light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["women","holding","hands:","medium-dark","skin","tone,","light","tone"]}, + {"emoji":"👩🏾‍🤝‍👩🏼","name":"women holding hands: medium-dark skin tone, medium-light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["women","holding","hands:","medium-dark","skin","tone,","medium-light","tone"]}, + {"emoji":"👩🏾‍🤝‍👩🏽","name":"women holding hands: medium-dark skin tone, medium skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["women","holding","hands:","medium-dark","skin","tone,","medium","tone"]}, + {"emoji":"👭🏾","name":"women holding hands: medium-dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["women","holding","hands:","medium-dark","skin","tone"]}, + {"emoji":"👩🏾‍🤝‍👩🏿","name":"women holding hands: medium-dark skin tone, dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["women","holding","hands:","medium-dark","skin","tone,","dark","tone"]}, + {"emoji":"👩🏿‍🤝‍👩🏻","name":"women holding hands: dark skin tone, light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["women","holding","hands:","dark","skin","tone,","light","tone"]}, + {"emoji":"👩🏿‍🤝‍👩🏼","name":"women holding hands: dark skin tone, medium-light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["women","holding","hands:","dark","skin","tone,","medium-light","tone"]}, + {"emoji":"👩🏿‍🤝‍👩🏽","name":"women holding hands: dark skin tone, medium skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["women","holding","hands:","dark","skin","tone,","medium","tone"]}, + {"emoji":"👩🏿‍🤝‍👩🏾","name":"women holding hands: dark skin tone, medium-dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["women","holding","hands:","dark","skin","tone,","medium-dark","tone"]}, + {"emoji":"👭🏿","name":"women holding hands: dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["women","holding","hands:","dark","skin","tone"]}, + {"emoji":"👫","name":"woman and man holding hands","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["woman","and","man","holding","hands","bae","bestie","bff","couple","dating","flirt","friends","hand","hold","twins"]}, + {"emoji":"👫🏻","name":"woman and man holding hands: light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","and","man","holding","hands:","light","skin","tone"]}, + {"emoji":"👩🏻‍🤝‍👨🏼","name":"woman and man holding hands: light skin tone, medium-light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","and","man","holding","hands:","light","skin","tone,","medium-light","tone"]}, + {"emoji":"👩🏻‍🤝‍👨🏽","name":"woman and man holding hands: light skin tone, medium skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","and","man","holding","hands:","light","skin","tone,","medium","tone"]}, + {"emoji":"👩🏻‍🤝‍👨🏾","name":"woman and man holding hands: light skin tone, medium-dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","and","man","holding","hands:","light","skin","tone,","medium-dark","tone"]}, + {"emoji":"👩🏻‍🤝‍👨🏿","name":"woman and man holding hands: light skin tone, dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","and","man","holding","hands:","light","skin","tone,","dark","tone"]}, + {"emoji":"👩🏼‍🤝‍👨🏻","name":"woman and man holding hands: medium-light skin tone, light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","and","man","holding","hands:","medium-light","skin","tone,","light","tone"]}, + {"emoji":"👫🏼","name":"woman and man holding hands: medium-light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","and","man","holding","hands:","medium-light","skin","tone"]}, + {"emoji":"👩🏼‍🤝‍👨🏽","name":"woman and man holding hands: medium-light skin tone, medium skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","and","man","holding","hands:","medium-light","skin","tone,","medium","tone"]}, + {"emoji":"👩🏼‍🤝‍👨🏾","name":"woman and man holding hands: medium-light skin tone, medium-dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","and","man","holding","hands:","medium-light","skin","tone,","medium-dark","tone"]}, + {"emoji":"👩🏼‍🤝‍👨🏿","name":"woman and man holding hands: medium-light skin tone, dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","and","man","holding","hands:","medium-light","skin","tone,","dark","tone"]}, + {"emoji":"👩🏽‍🤝‍👨🏻","name":"woman and man holding hands: medium skin tone, light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","and","man","holding","hands:","medium","skin","tone,","light","tone"]}, + {"emoji":"👩🏽‍🤝‍👨🏼","name":"woman and man holding hands: medium skin tone, medium-light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","and","man","holding","hands:","medium","skin","tone,","medium-light","tone"]}, + {"emoji":"👫🏽","name":"woman and man holding hands: medium skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","and","man","holding","hands:","medium","skin","tone"]}, + {"emoji":"👩🏽‍🤝‍👨🏾","name":"woman and man holding hands: medium skin tone, medium-dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","and","man","holding","hands:","medium","skin","tone,","medium-dark","tone"]}, + {"emoji":"👩🏽‍🤝‍👨🏿","name":"woman and man holding hands: medium skin tone, dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","and","man","holding","hands:","medium","skin","tone,","dark","tone"]}, + {"emoji":"👩🏾‍🤝‍👨🏻","name":"woman and man holding hands: medium-dark skin tone, light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","and","man","holding","hands:","medium-dark","skin","tone,","light","tone"]}, + {"emoji":"👩🏾‍🤝‍👨🏼","name":"woman and man holding hands: medium-dark skin tone, medium-light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","and","man","holding","hands:","medium-dark","skin","tone,","medium-light","tone"]}, + {"emoji":"👩🏾‍🤝‍👨🏽","name":"woman and man holding hands: medium-dark skin tone, medium skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","and","man","holding","hands:","medium-dark","skin","tone,","medium","tone"]}, + {"emoji":"👫🏾","name":"woman and man holding hands: medium-dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","and","man","holding","hands:","medium-dark","skin","tone"]}, + {"emoji":"👩🏾‍🤝‍👨🏿","name":"woman and man holding hands: medium-dark skin tone, dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","and","man","holding","hands:","medium-dark","skin","tone,","dark","tone"]}, + {"emoji":"👩🏿‍🤝‍👨🏻","name":"woman and man holding hands: dark skin tone, light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","and","man","holding","hands:","dark","skin","tone,","light","tone"]}, + {"emoji":"👩🏿‍🤝‍👨🏼","name":"woman and man holding hands: dark skin tone, medium-light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","and","man","holding","hands:","dark","skin","tone,","medium-light","tone"]}, + {"emoji":"👩🏿‍🤝‍👨🏽","name":"woman and man holding hands: dark skin tone, medium skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","and","man","holding","hands:","dark","skin","tone,","medium","tone"]}, + {"emoji":"👩🏿‍🤝‍👨🏾","name":"woman and man holding hands: dark skin tone, medium-dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","and","man","holding","hands:","dark","skin","tone,","medium-dark","tone"]}, + {"emoji":"👫🏿","name":"woman and man holding hands: dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["woman","and","man","holding","hands:","dark","skin","tone"]}, + {"emoji":"👬","name":"men holding hands","category":"Smileys & People","version":"1.0","skinTone":false,"keywords":["men","holding","hands","bae","bestie","bff","boys","brothers","couple","dating","flirt","friends","hand","hold","twins"]}, + {"emoji":"👬🏻","name":"men holding hands: light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["men","holding","hands:","light","skin","tone"]}, + {"emoji":"👨🏻‍🤝‍👨🏼","name":"men holding hands: light skin tone, medium-light skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["men","holding","hands:","light","skin","tone,","medium-light","tone"]}, + {"emoji":"👨🏻‍🤝‍👨🏽","name":"men holding hands: light skin tone, medium skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["men","holding","hands:","light","skin","tone,","medium","tone"]}, + {"emoji":"👨🏻‍🤝‍👨🏾","name":"men holding hands: light skin tone, medium-dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["men","holding","hands:","light","skin","tone,","medium-dark","tone"]}, + {"emoji":"👨🏻‍🤝‍👨🏿","name":"men holding hands: light skin tone, dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["men","holding","hands:","light","skin","tone,","dark","tone"]}, + {"emoji":"👨🏼‍🤝‍👨🏻","name":"men holding hands: medium-light skin tone, light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["men","holding","hands:","medium-light","skin","tone,","light","tone"]}, + {"emoji":"👬🏼","name":"men holding hands: medium-light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["men","holding","hands:","medium-light","skin","tone"]}, + {"emoji":"👨🏼‍🤝‍👨🏽","name":"men holding hands: medium-light skin tone, medium skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["men","holding","hands:","medium-light","skin","tone,","medium","tone"]}, + {"emoji":"👨🏼‍🤝‍👨🏾","name":"men holding hands: medium-light skin tone, medium-dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["men","holding","hands:","medium-light","skin","tone,","medium-dark","tone"]}, + {"emoji":"👨🏼‍🤝‍👨🏿","name":"men holding hands: medium-light skin tone, dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["men","holding","hands:","medium-light","skin","tone,","dark","tone"]}, + {"emoji":"👨🏽‍🤝‍👨🏻","name":"men holding hands: medium skin tone, light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["men","holding","hands:","medium","skin","tone,","light","tone"]}, + {"emoji":"👨🏽‍🤝‍👨🏼","name":"men holding hands: medium skin tone, medium-light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["men","holding","hands:","medium","skin","tone,","medium-light","tone"]}, + {"emoji":"👬🏽","name":"men holding hands: medium skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["men","holding","hands:","medium","skin","tone"]}, + {"emoji":"👨🏽‍🤝‍👨🏾","name":"men holding hands: medium skin tone, medium-dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["men","holding","hands:","medium","skin","tone,","medium-dark","tone"]}, + {"emoji":"👨🏽‍🤝‍👨🏿","name":"men holding hands: medium skin tone, dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["men","holding","hands:","medium","skin","tone,","dark","tone"]}, + {"emoji":"👨🏾‍🤝‍👨🏻","name":"men holding hands: medium-dark skin tone, light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["men","holding","hands:","medium-dark","skin","tone,","light","tone"]}, + {"emoji":"👨🏾‍🤝‍👨🏼","name":"men holding hands: medium-dark skin tone, medium-light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["men","holding","hands:","medium-dark","skin","tone,","medium-light","tone"]}, + {"emoji":"👨🏾‍🤝‍👨🏽","name":"men holding hands: medium-dark skin tone, medium skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["men","holding","hands:","medium-dark","skin","tone,","medium","tone"]}, + {"emoji":"👬🏾","name":"men holding hands: medium-dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["men","holding","hands:","medium-dark","skin","tone"]}, + {"emoji":"👨🏾‍🤝‍👨🏿","name":"men holding hands: medium-dark skin tone, dark skin tone","category":"Smileys & People","version":"12.1","skinTone":true,"keywords":["men","holding","hands:","medium-dark","skin","tone,","dark","tone"]}, + {"emoji":"👨🏿‍🤝‍👨🏻","name":"men holding hands: dark skin tone, light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["men","holding","hands:","dark","skin","tone,","light","tone"]}, + {"emoji":"👨🏿‍🤝‍👨🏼","name":"men holding hands: dark skin tone, medium-light skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["men","holding","hands:","dark","skin","tone,","medium-light","tone"]}, + {"emoji":"👨🏿‍🤝‍👨🏽","name":"men holding hands: dark skin tone, medium skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["men","holding","hands:","dark","skin","tone,","medium","tone"]}, + {"emoji":"👨🏿‍🤝‍👨🏾","name":"men holding hands: dark skin tone, medium-dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["men","holding","hands:","dark","skin","tone,","medium-dark","tone"]}, + {"emoji":"👬🏿","name":"men holding hands: dark skin tone","category":"Smileys & People","version":"12.0","skinTone":true,"keywords":["men","holding","hands:","dark","skin","tone"]}, + {"emoji":"💏","name":"kiss","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["kiss","anniversary","babe","bae","couple","date","dating","heart","love","mwah","person","romance","together","xoxo"]}, + {"emoji":"💏🏻","name":"kiss: light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","light","skin","tone"]}, + {"emoji":"💏🏼","name":"kiss: medium-light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","medium-light","skin","tone"]}, + {"emoji":"💏🏽","name":"kiss: medium skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","medium","skin","tone"]}, + {"emoji":"💏🏾","name":"kiss: medium-dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","medium-dark","skin","tone"]}, + {"emoji":"💏🏿","name":"kiss: dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","dark","skin","tone"]}, + {"emoji":"🧑🏻‍❤️‍💋‍🧑🏼","name":"kiss: person, person, light skin tone, medium-light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","person,","light","skin","tone,","medium-light","tone"]}, + {"emoji":"🧑🏻‍❤️‍💋‍🧑🏽","name":"kiss: person, person, light skin tone, medium skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","person,","light","skin","tone,","medium","tone"]}, + {"emoji":"🧑🏻‍❤️‍💋‍🧑🏾","name":"kiss: person, person, light skin tone, medium-dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","person,","light","skin","tone,","medium-dark","tone"]}, + {"emoji":"🧑🏻‍❤️‍💋‍🧑🏿","name":"kiss: person, person, light skin tone, dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","person,","light","skin","tone,","dark","tone"]}, + {"emoji":"🧑🏼‍❤️‍💋‍🧑🏻","name":"kiss: person, person, medium-light skin tone, light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","person,","medium-light","skin","tone,","light","tone"]}, + {"emoji":"🧑🏼‍❤️‍💋‍🧑🏽","name":"kiss: person, person, medium-light skin tone, medium skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","person,","medium-light","skin","tone,","medium","tone"]}, + {"emoji":"🧑🏼‍❤️‍💋‍🧑🏾","name":"kiss: person, person, medium-light skin tone, medium-dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","person,","medium-light","skin","tone,","medium-dark","tone"]}, + {"emoji":"🧑🏼‍❤️‍💋‍🧑🏿","name":"kiss: person, person, medium-light skin tone, dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","person,","medium-light","skin","tone,","dark","tone"]}, + {"emoji":"🧑🏽‍❤️‍💋‍🧑🏻","name":"kiss: person, person, medium skin tone, light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","person,","medium","skin","tone,","light","tone"]}, + {"emoji":"🧑🏽‍❤️‍💋‍🧑🏼","name":"kiss: person, person, medium skin tone, medium-light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","person,","medium","skin","tone,","medium-light","tone"]}, + {"emoji":"🧑🏽‍❤️‍💋‍🧑🏾","name":"kiss: person, person, medium skin tone, medium-dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","person,","medium","skin","tone,","medium-dark","tone"]}, + {"emoji":"🧑🏽‍❤️‍💋‍🧑🏿","name":"kiss: person, person, medium skin tone, dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","person,","medium","skin","tone,","dark","tone"]}, + {"emoji":"🧑🏾‍❤️‍💋‍🧑🏻","name":"kiss: person, person, medium-dark skin tone, light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","person,","medium-dark","skin","tone,","light","tone"]}, + {"emoji":"🧑🏾‍❤️‍💋‍🧑🏼","name":"kiss: person, person, medium-dark skin tone, medium-light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","person,","medium-dark","skin","tone,","medium-light","tone"]}, + {"emoji":"🧑🏾‍❤️‍💋‍🧑🏽","name":"kiss: person, person, medium-dark skin tone, medium skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","person,","medium-dark","skin","tone,","medium","tone"]}, + {"emoji":"🧑🏾‍❤️‍💋‍🧑🏿","name":"kiss: person, person, medium-dark skin tone, dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","person,","medium-dark","skin","tone,","dark","tone"]}, + {"emoji":"🧑🏿‍❤️‍💋‍🧑🏻","name":"kiss: person, person, dark skin tone, light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","person,","dark","skin","tone,","light","tone"]}, + {"emoji":"🧑🏿‍❤️‍💋‍🧑🏼","name":"kiss: person, person, dark skin tone, medium-light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","person,","dark","skin","tone,","medium-light","tone"]}, + {"emoji":"🧑🏿‍❤️‍💋‍🧑🏽","name":"kiss: person, person, dark skin tone, medium skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","person,","dark","skin","tone,","medium","tone"]}, + {"emoji":"🧑🏿‍❤️‍💋‍🧑🏾","name":"kiss: person, person, dark skin tone, medium-dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","person,","dark","skin","tone,","medium-dark","tone"]}, + {"emoji":"👩‍❤️‍💋‍👨","name":"kiss: woman, man","category":"Smileys & People","version":"2.0","skinTone":false,"keywords":["kiss:","woman,","man"]}, + {"emoji":"👩🏻‍❤️‍💋‍👨🏻","name":"kiss: woman, man, light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","man,","light","skin","tone"]}, + {"emoji":"👩🏻‍❤️‍💋‍👨🏼","name":"kiss: woman, man, light skin tone, medium-light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","man,","light","skin","tone,","medium-light","tone"]}, + {"emoji":"👩🏻‍❤️‍💋‍👨🏽","name":"kiss: woman, man, light skin tone, medium skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","man,","light","skin","tone,","medium","tone"]}, + {"emoji":"👩🏻‍❤️‍💋‍👨🏾","name":"kiss: woman, man, light skin tone, medium-dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","man,","light","skin","tone,","medium-dark","tone"]}, + {"emoji":"👩🏻‍❤️‍💋‍👨🏿","name":"kiss: woman, man, light skin tone, dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","man,","light","skin","tone,","dark","tone"]}, + {"emoji":"👩🏼‍❤️‍💋‍👨🏻","name":"kiss: woman, man, medium-light skin tone, light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","man,","medium-light","skin","tone,","light","tone"]}, + {"emoji":"👩🏼‍❤️‍💋‍👨🏼","name":"kiss: woman, man, medium-light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","man,","medium-light","skin","tone"]}, + {"emoji":"👩🏼‍❤️‍💋‍👨🏽","name":"kiss: woman, man, medium-light skin tone, medium skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","man,","medium-light","skin","tone,","medium","tone"]}, + {"emoji":"👩🏼‍❤️‍💋‍👨🏾","name":"kiss: woman, man, medium-light skin tone, medium-dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","man,","medium-light","skin","tone,","medium-dark","tone"]}, + {"emoji":"👩🏼‍❤️‍💋‍👨🏿","name":"kiss: woman, man, medium-light skin tone, dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","man,","medium-light","skin","tone,","dark","tone"]}, + {"emoji":"👩🏽‍❤️‍💋‍👨🏻","name":"kiss: woman, man, medium skin tone, light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","man,","medium","skin","tone,","light","tone"]}, + {"emoji":"👩🏽‍❤️‍💋‍👨🏼","name":"kiss: woman, man, medium skin tone, medium-light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","man,","medium","skin","tone,","medium-light","tone"]}, + {"emoji":"👩🏽‍❤️‍💋‍👨🏽","name":"kiss: woman, man, medium skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","man,","medium","skin","tone"]}, + {"emoji":"👩🏽‍❤️‍💋‍👨🏾","name":"kiss: woman, man, medium skin tone, medium-dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","man,","medium","skin","tone,","medium-dark","tone"]}, + {"emoji":"👩🏽‍❤️‍💋‍👨🏿","name":"kiss: woman, man, medium skin tone, dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","man,","medium","skin","tone,","dark","tone"]}, + {"emoji":"👩🏾‍❤️‍💋‍👨🏻","name":"kiss: woman, man, medium-dark skin tone, light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","man,","medium-dark","skin","tone,","light","tone"]}, + {"emoji":"👩🏾‍❤️‍💋‍👨🏼","name":"kiss: woman, man, medium-dark skin tone, medium-light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","man,","medium-dark","skin","tone,","medium-light","tone"]}, + {"emoji":"👩🏾‍❤️‍💋‍👨🏽","name":"kiss: woman, man, medium-dark skin tone, medium skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","man,","medium-dark","skin","tone,","medium","tone"]}, + {"emoji":"👩🏾‍❤️‍💋‍👨🏾","name":"kiss: woman, man, medium-dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","man,","medium-dark","skin","tone"]}, + {"emoji":"👩🏾‍❤️‍💋‍👨🏿","name":"kiss: woman, man, medium-dark skin tone, dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","man,","medium-dark","skin","tone,","dark","tone"]}, + {"emoji":"👩🏿‍❤️‍💋‍👨🏻","name":"kiss: woman, man, dark skin tone, light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","man,","dark","skin","tone,","light","tone"]}, + {"emoji":"👩🏿‍❤️‍💋‍👨🏼","name":"kiss: woman, man, dark skin tone, medium-light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","man,","dark","skin","tone,","medium-light","tone"]}, + {"emoji":"👩🏿‍❤️‍💋‍👨🏽","name":"kiss: woman, man, dark skin tone, medium skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","man,","dark","skin","tone,","medium","tone"]}, + {"emoji":"👩🏿‍❤️‍💋‍👨🏾","name":"kiss: woman, man, dark skin tone, medium-dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","man,","dark","skin","tone,","medium-dark","tone"]}, + {"emoji":"👩🏿‍❤️‍💋‍👨🏿","name":"kiss: woman, man, dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","man,","dark","skin","tone"]}, + {"emoji":"👨‍❤️‍💋‍👨","name":"kiss: man, man","category":"Smileys & People","version":"2.0","skinTone":false,"keywords":["kiss:","man,","man"]}, + {"emoji":"👨🏻‍❤️‍💋‍👨🏻","name":"kiss: man, man, light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","man,","light","skin","tone"]}, + {"emoji":"👨🏻‍❤️‍💋‍👨🏼","name":"kiss: man, man, light skin tone, medium-light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","man,","light","skin","tone,","medium-light","tone"]}, + {"emoji":"👨🏻‍❤️‍💋‍👨🏽","name":"kiss: man, man, light skin tone, medium skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","man,","light","skin","tone,","medium","tone"]}, + {"emoji":"👨🏻‍❤️‍💋‍👨🏾","name":"kiss: man, man, light skin tone, medium-dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","man,","light","skin","tone,","medium-dark","tone"]}, + {"emoji":"👨🏻‍❤️‍💋‍👨🏿","name":"kiss: man, man, light skin tone, dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","man,","light","skin","tone,","dark","tone"]}, + {"emoji":"👨🏼‍❤️‍💋‍👨🏻","name":"kiss: man, man, medium-light skin tone, light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","man,","medium-light","skin","tone,","light","tone"]}, + {"emoji":"👨🏼‍❤️‍💋‍👨🏼","name":"kiss: man, man, medium-light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","man,","medium-light","skin","tone"]}, + {"emoji":"👨🏼‍❤️‍💋‍👨🏽","name":"kiss: man, man, medium-light skin tone, medium skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","man,","medium-light","skin","tone,","medium","tone"]}, + {"emoji":"👨🏼‍❤️‍💋‍👨🏾","name":"kiss: man, man, medium-light skin tone, medium-dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","man,","medium-light","skin","tone,","medium-dark","tone"]}, + {"emoji":"👨🏼‍❤️‍💋‍👨🏿","name":"kiss: man, man, medium-light skin tone, dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","man,","medium-light","skin","tone,","dark","tone"]}, + {"emoji":"👨🏽‍❤️‍💋‍👨🏻","name":"kiss: man, man, medium skin tone, light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","man,","medium","skin","tone,","light","tone"]}, + {"emoji":"👨🏽‍❤️‍💋‍👨🏼","name":"kiss: man, man, medium skin tone, medium-light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","man,","medium","skin","tone,","medium-light","tone"]}, + {"emoji":"👨🏽‍❤️‍💋‍👨🏽","name":"kiss: man, man, medium skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","man,","medium","skin","tone"]}, + {"emoji":"👨🏽‍❤️‍💋‍👨🏾","name":"kiss: man, man, medium skin tone, medium-dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","man,","medium","skin","tone,","medium-dark","tone"]}, + {"emoji":"👨🏽‍❤️‍💋‍👨🏿","name":"kiss: man, man, medium skin tone, dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","man,","medium","skin","tone,","dark","tone"]}, + {"emoji":"👨🏾‍❤️‍💋‍👨🏻","name":"kiss: man, man, medium-dark skin tone, light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","man,","medium-dark","skin","tone,","light","tone"]}, + {"emoji":"👨🏾‍❤️‍💋‍👨🏼","name":"kiss: man, man, medium-dark skin tone, medium-light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","man,","medium-dark","skin","tone,","medium-light","tone"]}, + {"emoji":"👨🏾‍❤️‍💋‍👨🏽","name":"kiss: man, man, medium-dark skin tone, medium skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","man,","medium-dark","skin","tone,","medium","tone"]}, + {"emoji":"👨🏾‍❤️‍💋‍👨🏾","name":"kiss: man, man, medium-dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","man,","medium-dark","skin","tone"]}, + {"emoji":"👨🏾‍❤️‍💋‍👨🏿","name":"kiss: man, man, medium-dark skin tone, dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","man,","medium-dark","skin","tone,","dark","tone"]}, + {"emoji":"👨🏿‍❤️‍💋‍👨🏻","name":"kiss: man, man, dark skin tone, light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","man,","dark","skin","tone,","light","tone"]}, + {"emoji":"👨🏿‍❤️‍💋‍👨🏼","name":"kiss: man, man, dark skin tone, medium-light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","man,","dark","skin","tone,","medium-light","tone"]}, + {"emoji":"👨🏿‍❤️‍💋‍👨🏽","name":"kiss: man, man, dark skin tone, medium skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","man,","dark","skin","tone,","medium","tone"]}, + {"emoji":"👨🏿‍❤️‍💋‍👨🏾","name":"kiss: man, man, dark skin tone, medium-dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","man,","dark","skin","tone,","medium-dark","tone"]}, + {"emoji":"👨🏿‍❤️‍💋‍👨🏿","name":"kiss: man, man, dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","man,","dark","skin","tone"]}, + {"emoji":"👩‍❤️‍💋‍👩","name":"kiss: woman, woman","category":"Smileys & People","version":"2.0","skinTone":false,"keywords":["kiss:","woman,","woman"]}, + {"emoji":"👩🏻‍❤️‍💋‍👩🏻","name":"kiss: woman, woman, light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","light","skin","tone"]}, + {"emoji":"👩🏻‍❤️‍💋‍👩🏼","name":"kiss: woman, woman, light skin tone, medium-light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","light","skin","tone,","medium-light","tone"]}, + {"emoji":"👩🏻‍❤️‍💋‍👩🏽","name":"kiss: woman, woman, light skin tone, medium skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","light","skin","tone,","medium","tone"]}, + {"emoji":"👩🏻‍❤️‍💋‍👩🏾","name":"kiss: woman, woman, light skin tone, medium-dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","light","skin","tone,","medium-dark","tone"]}, + {"emoji":"👩🏻‍❤️‍💋‍👩🏿","name":"kiss: woman, woman, light skin tone, dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","light","skin","tone,","dark","tone"]}, + {"emoji":"👩🏼‍❤️‍💋‍👩🏻","name":"kiss: woman, woman, medium-light skin tone, light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","medium-light","skin","tone,","light","tone"]}, + {"emoji":"👩🏼‍❤️‍💋‍👩🏼","name":"kiss: woman, woman, medium-light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","medium-light","skin","tone"]}, + {"emoji":"👩🏼‍❤️‍💋‍👩🏽","name":"kiss: woman, woman, medium-light skin tone, medium skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","medium-light","skin","tone,","medium","tone"]}, + {"emoji":"👩🏼‍❤️‍💋‍👩🏾","name":"kiss: woman, woman, medium-light skin tone, medium-dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","medium-light","skin","tone,","medium-dark","tone"]}, + {"emoji":"👩🏼‍❤️‍💋‍👩🏿","name":"kiss: woman, woman, medium-light skin tone, dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","medium-light","skin","tone,","dark","tone"]}, + {"emoji":"👩🏽‍❤️‍💋‍👩🏻","name":"kiss: woman, woman, medium skin tone, light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","medium","skin","tone,","light","tone"]}, + {"emoji":"👩🏽‍❤️‍💋‍👩🏼","name":"kiss: woman, woman, medium skin tone, medium-light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","medium","skin","tone,","medium-light","tone"]}, + {"emoji":"👩🏽‍❤️‍💋‍👩🏽","name":"kiss: woman, woman, medium skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","medium","skin","tone"]}, + {"emoji":"👩🏽‍❤️‍💋‍👩🏾","name":"kiss: woman, woman, medium skin tone, medium-dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","medium","skin","tone,","medium-dark","tone"]}, + {"emoji":"👩🏽‍❤️‍💋‍👩🏿","name":"kiss: woman, woman, medium skin tone, dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","medium","skin","tone,","dark","tone"]}, + {"emoji":"👩🏾‍❤️‍💋‍👩🏻","name":"kiss: woman, woman, medium-dark skin tone, light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","medium-dark","skin","tone,","light","tone"]}, + {"emoji":"👩🏾‍❤️‍💋‍👩🏼","name":"kiss: woman, woman, medium-dark skin tone, medium-light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","medium-dark","skin","tone,","medium-light","tone"]}, + {"emoji":"👩🏾‍❤️‍💋‍👩🏽","name":"kiss: woman, woman, medium-dark skin tone, medium skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","medium-dark","skin","tone,","medium","tone"]}, + {"emoji":"👩🏾‍❤️‍💋‍👩🏾","name":"kiss: woman, woman, medium-dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","medium-dark","skin","tone"]}, + {"emoji":"👩🏾‍❤️‍💋‍👩🏿","name":"kiss: woman, woman, medium-dark skin tone, dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","medium-dark","skin","tone,","dark","tone"]}, + {"emoji":"👩🏿‍❤️‍💋‍👩🏻","name":"kiss: woman, woman, dark skin tone, light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","dark","skin","tone,","light","tone"]}, + {"emoji":"👩🏿‍❤️‍💋‍👩🏼","name":"kiss: woman, woman, dark skin tone, medium-light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","dark","skin","tone,","medium-light","tone"]}, + {"emoji":"👩🏿‍❤️‍💋‍👩🏽","name":"kiss: woman, woman, dark skin tone, medium skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","dark","skin","tone,","medium","tone"]}, + {"emoji":"👩🏿‍❤️‍💋‍👩🏾","name":"kiss: woman, woman, dark skin tone, medium-dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","dark","skin","tone,","medium-dark","tone"]}, + {"emoji":"👩🏿‍❤️‍💋‍👩🏿","name":"kiss: woman, woman, dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["kiss:","woman,","dark","skin","tone"]}, + {"emoji":"💑","name":"couple with heart","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["couple","with","heart","anniversary","babe","bae","dating","kiss","love","person","relationship","romance","together","you"]}, + {"emoji":"💑🏻","name":"couple with heart: light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","light","skin","tone"]}, + {"emoji":"💑🏼","name":"couple with heart: medium-light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","medium-light","skin","tone"]}, + {"emoji":"💑🏽","name":"couple with heart: medium skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","medium","skin","tone"]}, + {"emoji":"💑🏾","name":"couple with heart: medium-dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","medium-dark","skin","tone"]}, + {"emoji":"💑🏿","name":"couple with heart: dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","dark","skin","tone"]}, + {"emoji":"🧑🏻‍❤️‍🧑🏼","name":"couple with heart: person, person, light skin tone, medium-light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","person,","light","skin","tone,","medium-light","tone"]}, + {"emoji":"🧑🏻‍❤️‍🧑🏽","name":"couple with heart: person, person, light skin tone, medium skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","person,","light","skin","tone,","medium","tone"]}, + {"emoji":"🧑🏻‍❤️‍🧑🏾","name":"couple with heart: person, person, light skin tone, medium-dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","person,","light","skin","tone,","medium-dark","tone"]}, + {"emoji":"🧑🏻‍❤️‍🧑🏿","name":"couple with heart: person, person, light skin tone, dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","person,","light","skin","tone,","dark","tone"]}, + {"emoji":"🧑🏼‍❤️‍🧑🏻","name":"couple with heart: person, person, medium-light skin tone, light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","person,","medium-light","skin","tone,","light","tone"]}, + {"emoji":"🧑🏼‍❤️‍🧑🏽","name":"couple with heart: person, person, medium-light skin tone, medium skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","person,","medium-light","skin","tone,","medium","tone"]}, + {"emoji":"🧑🏼‍❤️‍🧑🏾","name":"couple with heart: person, person, medium-light skin tone, medium-dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","person,","medium-light","skin","tone,","medium-dark","tone"]}, + {"emoji":"🧑🏼‍❤️‍🧑🏿","name":"couple with heart: person, person, medium-light skin tone, dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","person,","medium-light","skin","tone,","dark","tone"]}, + {"emoji":"🧑🏽‍❤️‍🧑🏻","name":"couple with heart: person, person, medium skin tone, light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","person,","medium","skin","tone,","light","tone"]}, + {"emoji":"🧑🏽‍❤️‍🧑🏼","name":"couple with heart: person, person, medium skin tone, medium-light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","person,","medium","skin","tone,","medium-light","tone"]}, + {"emoji":"🧑🏽‍❤️‍🧑🏾","name":"couple with heart: person, person, medium skin tone, medium-dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","person,","medium","skin","tone,","medium-dark","tone"]}, + {"emoji":"🧑🏽‍❤️‍🧑🏿","name":"couple with heart: person, person, medium skin tone, dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","person,","medium","skin","tone,","dark","tone"]}, + {"emoji":"🧑🏾‍❤️‍🧑🏻","name":"couple with heart: person, person, medium-dark skin tone, light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","person,","medium-dark","skin","tone,","light","tone"]}, + {"emoji":"🧑🏾‍❤️‍🧑🏼","name":"couple with heart: person, person, medium-dark skin tone, medium-light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","person,","medium-dark","skin","tone,","medium-light","tone"]}, + {"emoji":"🧑🏾‍❤️‍🧑🏽","name":"couple with heart: person, person, medium-dark skin tone, medium skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","person,","medium-dark","skin","tone,","medium","tone"]}, + {"emoji":"🧑🏾‍❤️‍🧑🏿","name":"couple with heart: person, person, medium-dark skin tone, dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","person,","medium-dark","skin","tone,","dark","tone"]}, + {"emoji":"🧑🏿‍❤️‍🧑🏻","name":"couple with heart: person, person, dark skin tone, light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","person,","dark","skin","tone,","light","tone"]}, + {"emoji":"🧑🏿‍❤️‍🧑🏼","name":"couple with heart: person, person, dark skin tone, medium-light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","person,","dark","skin","tone,","medium-light","tone"]}, + {"emoji":"🧑🏿‍❤️‍🧑🏽","name":"couple with heart: person, person, dark skin tone, medium skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","person,","dark","skin","tone,","medium","tone"]}, + {"emoji":"🧑🏿‍❤️‍🧑🏾","name":"couple with heart: person, person, dark skin tone, medium-dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","person,","dark","skin","tone,","medium-dark","tone"]}, + {"emoji":"👩‍❤️‍👨","name":"couple with heart: woman, man","category":"Smileys & People","version":"2.0","skinTone":false,"keywords":["couple","with","heart:","woman,","man"]}, + {"emoji":"👩🏻‍❤️‍👨🏻","name":"couple with heart: woman, man, light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","man,","light","skin","tone"]}, + {"emoji":"👩🏻‍❤️‍👨🏼","name":"couple with heart: woman, man, light skin tone, medium-light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","man,","light","skin","tone,","medium-light","tone"]}, + {"emoji":"👩🏻‍❤️‍👨🏽","name":"couple with heart: woman, man, light skin tone, medium skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","man,","light","skin","tone,","medium","tone"]}, + {"emoji":"👩🏻‍❤️‍👨🏾","name":"couple with heart: woman, man, light skin tone, medium-dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","man,","light","skin","tone,","medium-dark","tone"]}, + {"emoji":"👩🏻‍❤️‍👨🏿","name":"couple with heart: woman, man, light skin tone, dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","man,","light","skin","tone,","dark","tone"]}, + {"emoji":"👩🏼‍❤️‍👨🏻","name":"couple with heart: woman, man, medium-light skin tone, light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","man,","medium-light","skin","tone,","light","tone"]}, + {"emoji":"👩🏼‍❤️‍👨🏼","name":"couple with heart: woman, man, medium-light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","man,","medium-light","skin","tone"]}, + {"emoji":"👩🏼‍❤️‍👨🏽","name":"couple with heart: woman, man, medium-light skin tone, medium skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","man,","medium-light","skin","tone,","medium","tone"]}, + {"emoji":"👩🏼‍❤️‍👨🏾","name":"couple with heart: woman, man, medium-light skin tone, medium-dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","man,","medium-light","skin","tone,","medium-dark","tone"]}, + {"emoji":"👩🏼‍❤️‍👨🏿","name":"couple with heart: woman, man, medium-light skin tone, dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","man,","medium-light","skin","tone,","dark","tone"]}, + {"emoji":"👩🏽‍❤️‍👨🏻","name":"couple with heart: woman, man, medium skin tone, light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","man,","medium","skin","tone,","light","tone"]}, + {"emoji":"👩🏽‍❤️‍👨🏼","name":"couple with heart: woman, man, medium skin tone, medium-light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","man,","medium","skin","tone,","medium-light","tone"]}, + {"emoji":"👩🏽‍❤️‍👨🏽","name":"couple with heart: woman, man, medium skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","man,","medium","skin","tone"]}, + {"emoji":"👩🏽‍❤️‍👨🏾","name":"couple with heart: woman, man, medium skin tone, medium-dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","man,","medium","skin","tone,","medium-dark","tone"]}, + {"emoji":"👩🏽‍❤️‍👨🏿","name":"couple with heart: woman, man, medium skin tone, dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","man,","medium","skin","tone,","dark","tone"]}, + {"emoji":"👩🏾‍❤️‍👨🏻","name":"couple with heart: woman, man, medium-dark skin tone, light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","man,","medium-dark","skin","tone,","light","tone"]}, + {"emoji":"👩🏾‍❤️‍👨🏼","name":"couple with heart: woman, man, medium-dark skin tone, medium-light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","man,","medium-dark","skin","tone,","medium-light","tone"]}, + {"emoji":"👩🏾‍❤️‍👨🏽","name":"couple with heart: woman, man, medium-dark skin tone, medium skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","man,","medium-dark","skin","tone,","medium","tone"]}, + {"emoji":"👩🏾‍❤️‍👨🏾","name":"couple with heart: woman, man, medium-dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","man,","medium-dark","skin","tone"]}, + {"emoji":"👩🏾‍❤️‍👨🏿","name":"couple with heart: woman, man, medium-dark skin tone, dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","man,","medium-dark","skin","tone,","dark","tone"]}, + {"emoji":"👩🏿‍❤️‍👨🏻","name":"couple with heart: woman, man, dark skin tone, light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","man,","dark","skin","tone,","light","tone"]}, + {"emoji":"👩🏿‍❤️‍👨🏼","name":"couple with heart: woman, man, dark skin tone, medium-light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","man,","dark","skin","tone,","medium-light","tone"]}, + {"emoji":"👩🏿‍❤️‍👨🏽","name":"couple with heart: woman, man, dark skin tone, medium skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","man,","dark","skin","tone,","medium","tone"]}, + {"emoji":"👩🏿‍❤️‍👨🏾","name":"couple with heart: woman, man, dark skin tone, medium-dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","man,","dark","skin","tone,","medium-dark","tone"]}, + {"emoji":"👩🏿‍❤️‍👨🏿","name":"couple with heart: woman, man, dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","man,","dark","skin","tone"]}, + {"emoji":"👨‍❤️‍👨","name":"couple with heart: man, man","category":"Smileys & People","version":"2.0","skinTone":false,"keywords":["couple","with","heart:","man,","man"]}, + {"emoji":"👨🏻‍❤️‍👨🏻","name":"couple with heart: man, man, light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","man,","light","skin","tone"]}, + {"emoji":"👨🏻‍❤️‍👨🏼","name":"couple with heart: man, man, light skin tone, medium-light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","man,","light","skin","tone,","medium-light","tone"]}, + {"emoji":"👨🏻‍❤️‍👨🏽","name":"couple with heart: man, man, light skin tone, medium skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","man,","light","skin","tone,","medium","tone"]}, + {"emoji":"👨🏻‍❤️‍👨🏾","name":"couple with heart: man, man, light skin tone, medium-dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","man,","light","skin","tone,","medium-dark","tone"]}, + {"emoji":"👨🏻‍❤️‍👨🏿","name":"couple with heart: man, man, light skin tone, dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","man,","light","skin","tone,","dark","tone"]}, + {"emoji":"👨🏼‍❤️‍👨🏻","name":"couple with heart: man, man, medium-light skin tone, light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","man,","medium-light","skin","tone,","light","tone"]}, + {"emoji":"👨🏼‍❤️‍👨🏼","name":"couple with heart: man, man, medium-light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","man,","medium-light","skin","tone"]}, + {"emoji":"👨🏼‍❤️‍👨🏽","name":"couple with heart: man, man, medium-light skin tone, medium skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","man,","medium-light","skin","tone,","medium","tone"]}, + {"emoji":"👨🏼‍❤️‍👨🏾","name":"couple with heart: man, man, medium-light skin tone, medium-dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","man,","medium-light","skin","tone,","medium-dark","tone"]}, + {"emoji":"👨🏼‍❤️‍👨🏿","name":"couple with heart: man, man, medium-light skin tone, dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","man,","medium-light","skin","tone,","dark","tone"]}, + {"emoji":"👨🏽‍❤️‍👨🏻","name":"couple with heart: man, man, medium skin tone, light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","man,","medium","skin","tone,","light","tone"]}, + {"emoji":"👨🏽‍❤️‍👨🏼","name":"couple with heart: man, man, medium skin tone, medium-light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","man,","medium","skin","tone,","medium-light","tone"]}, + {"emoji":"👨🏽‍❤️‍👨🏽","name":"couple with heart: man, man, medium skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","man,","medium","skin","tone"]}, + {"emoji":"👨🏽‍❤️‍👨🏾","name":"couple with heart: man, man, medium skin tone, medium-dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","man,","medium","skin","tone,","medium-dark","tone"]}, + {"emoji":"👨🏽‍❤️‍👨🏿","name":"couple with heart: man, man, medium skin tone, dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","man,","medium","skin","tone,","dark","tone"]}, + {"emoji":"👨🏾‍❤️‍👨🏻","name":"couple with heart: man, man, medium-dark skin tone, light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","man,","medium-dark","skin","tone,","light","tone"]}, + {"emoji":"👨🏾‍❤️‍👨🏼","name":"couple with heart: man, man, medium-dark skin tone, medium-light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","man,","medium-dark","skin","tone,","medium-light","tone"]}, + {"emoji":"👨🏾‍❤️‍👨🏽","name":"couple with heart: man, man, medium-dark skin tone, medium skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","man,","medium-dark","skin","tone,","medium","tone"]}, + {"emoji":"👨🏾‍❤️‍👨🏾","name":"couple with heart: man, man, medium-dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","man,","medium-dark","skin","tone"]}, + {"emoji":"👨🏾‍❤️‍👨🏿","name":"couple with heart: man, man, medium-dark skin tone, dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","man,","medium-dark","skin","tone,","dark","tone"]}, + {"emoji":"👨🏿‍❤️‍👨🏻","name":"couple with heart: man, man, dark skin tone, light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","man,","dark","skin","tone,","light","tone"]}, + {"emoji":"👨🏿‍❤️‍👨🏼","name":"couple with heart: man, man, dark skin tone, medium-light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","man,","dark","skin","tone,","medium-light","tone"]}, + {"emoji":"👨🏿‍❤️‍👨🏽","name":"couple with heart: man, man, dark skin tone, medium skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","man,","dark","skin","tone,","medium","tone"]}, + {"emoji":"👨🏿‍❤️‍👨🏾","name":"couple with heart: man, man, dark skin tone, medium-dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","man,","dark","skin","tone,","medium-dark","tone"]}, + {"emoji":"👨🏿‍❤️‍👨🏿","name":"couple with heart: man, man, dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","man,","dark","skin","tone"]}, + {"emoji":"👩‍❤️‍👩","name":"couple with heart: woman, woman","category":"Smileys & People","version":"2.0","skinTone":false,"keywords":["couple","with","heart:","woman,","woman"]}, + {"emoji":"👩🏻‍❤️‍👩🏻","name":"couple with heart: woman, woman, light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","light","skin","tone"]}, + {"emoji":"👩🏻‍❤️‍👩🏼","name":"couple with heart: woman, woman, light skin tone, medium-light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","light","skin","tone,","medium-light","tone"]}, + {"emoji":"👩🏻‍❤️‍👩🏽","name":"couple with heart: woman, woman, light skin tone, medium skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","light","skin","tone,","medium","tone"]}, + {"emoji":"👩🏻‍❤️‍👩🏾","name":"couple with heart: woman, woman, light skin tone, medium-dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","light","skin","tone,","medium-dark","tone"]}, + {"emoji":"👩🏻‍❤️‍👩🏿","name":"couple with heart: woman, woman, light skin tone, dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","light","skin","tone,","dark","tone"]}, + {"emoji":"👩🏼‍❤️‍👩🏻","name":"couple with heart: woman, woman, medium-light skin tone, light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","medium-light","skin","tone,","light","tone"]}, + {"emoji":"👩🏼‍❤️‍👩🏼","name":"couple with heart: woman, woman, medium-light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","medium-light","skin","tone"]}, + {"emoji":"👩🏼‍❤️‍👩🏽","name":"couple with heart: woman, woman, medium-light skin tone, medium skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","medium-light","skin","tone,","medium","tone"]}, + {"emoji":"👩🏼‍❤️‍👩🏾","name":"couple with heart: woman, woman, medium-light skin tone, medium-dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","medium-light","skin","tone,","medium-dark","tone"]}, + {"emoji":"👩🏼‍❤️‍👩🏿","name":"couple with heart: woman, woman, medium-light skin tone, dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","medium-light","skin","tone,","dark","tone"]}, + {"emoji":"👩🏽‍❤️‍👩🏻","name":"couple with heart: woman, woman, medium skin tone, light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","medium","skin","tone,","light","tone"]}, + {"emoji":"👩🏽‍❤️‍👩🏼","name":"couple with heart: woman, woman, medium skin tone, medium-light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","medium","skin","tone,","medium-light","tone"]}, + {"emoji":"👩🏽‍❤️‍👩🏽","name":"couple with heart: woman, woman, medium skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","medium","skin","tone"]}, + {"emoji":"👩🏽‍❤️‍👩🏾","name":"couple with heart: woman, woman, medium skin tone, medium-dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","medium","skin","tone,","medium-dark","tone"]}, + {"emoji":"👩🏽‍❤️‍👩🏿","name":"couple with heart: woman, woman, medium skin tone, dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","medium","skin","tone,","dark","tone"]}, + {"emoji":"👩🏾‍❤️‍👩🏻","name":"couple with heart: woman, woman, medium-dark skin tone, light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","medium-dark","skin","tone,","light","tone"]}, + {"emoji":"👩🏾‍❤️‍👩🏼","name":"couple with heart: woman, woman, medium-dark skin tone, medium-light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","medium-dark","skin","tone,","medium-light","tone"]}, + {"emoji":"👩🏾‍❤️‍👩🏽","name":"couple with heart: woman, woman, medium-dark skin tone, medium skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","medium-dark","skin","tone,","medium","tone"]}, + {"emoji":"👩🏾‍❤️‍👩🏾","name":"couple with heart: woman, woman, medium-dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","medium-dark","skin","tone"]}, + {"emoji":"👩🏾‍❤️‍👩🏿","name":"couple with heart: woman, woman, medium-dark skin tone, dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","medium-dark","skin","tone,","dark","tone"]}, + {"emoji":"👩🏿‍❤️‍👩🏻","name":"couple with heart: woman, woman, dark skin tone, light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","dark","skin","tone,","light","tone"]}, + {"emoji":"👩🏿‍❤️‍👩🏼","name":"couple with heart: woman, woman, dark skin tone, medium-light skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","dark","skin","tone,","medium-light","tone"]}, + {"emoji":"👩🏿‍❤️‍👩🏽","name":"couple with heart: woman, woman, dark skin tone, medium skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","dark","skin","tone,","medium","tone"]}, + {"emoji":"👩🏿‍❤️‍👩🏾","name":"couple with heart: woman, woman, dark skin tone, medium-dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","dark","skin","tone,","medium-dark","tone"]}, + {"emoji":"👩🏿‍❤️‍👩🏿","name":"couple with heart: woman, woman, dark skin tone","category":"Smileys & People","version":"13.1","skinTone":true,"keywords":["couple","with","heart:","woman,","dark","skin","tone"]}, + {"emoji":"👨‍👩‍👦","name":"family: man, woman, boy","category":"Smileys & People","version":"2.0","skinTone":false,"keywords":["family:","man,","woman,","boy"]}, + {"emoji":"👨‍👩‍👧","name":"family: man, woman, girl","category":"Smileys & People","version":"2.0","skinTone":false,"keywords":["family:","man,","woman,","girl"]}, + {"emoji":"👨‍👩‍👧‍👦","name":"family: man, woman, girl, boy","category":"Smileys & People","version":"2.0","skinTone":false,"keywords":["family:","man,","woman,","girl,","boy"]}, + {"emoji":"👨‍👩‍👦‍👦","name":"family: man, woman, boy, boy","category":"Smileys & People","version":"2.0","skinTone":false,"keywords":["family:","man,","woman,","boy,","boy"]}, + {"emoji":"👨‍👩‍👧‍👧","name":"family: man, woman, girl, girl","category":"Smileys & People","version":"2.0","skinTone":false,"keywords":["family:","man,","woman,","girl,","girl"]}, + {"emoji":"👨‍👨‍👦","name":"family: man, man, boy","category":"Smileys & People","version":"2.0","skinTone":false,"keywords":["family:","man,","boy"]}, + {"emoji":"👨‍👨‍👧","name":"family: man, man, girl","category":"Smileys & People","version":"2.0","skinTone":false,"keywords":["family:","man,","girl"]}, + {"emoji":"👨‍👨‍👧‍👦","name":"family: man, man, girl, boy","category":"Smileys & People","version":"2.0","skinTone":false,"keywords":["family:","man,","girl,","boy"]}, + {"emoji":"👨‍👨‍👦‍👦","name":"family: man, man, boy, boy","category":"Smileys & People","version":"2.0","skinTone":false,"keywords":["family:","man,","boy,","boy"]}, + {"emoji":"👨‍👨‍👧‍👧","name":"family: man, man, girl, girl","category":"Smileys & People","version":"2.0","skinTone":false,"keywords":["family:","man,","girl,","girl"]}, + {"emoji":"👩‍👩‍👦","name":"family: woman, woman, boy","category":"Smileys & People","version":"2.0","skinTone":false,"keywords":["family:","woman,","boy"]}, + {"emoji":"👩‍👩‍👧","name":"family: woman, woman, girl","category":"Smileys & People","version":"2.0","skinTone":false,"keywords":["family:","woman,","girl"]}, + {"emoji":"👩‍👩‍👧‍👦","name":"family: woman, woman, girl, boy","category":"Smileys & People","version":"2.0","skinTone":false,"keywords":["family:","woman,","girl,","boy"]}, + {"emoji":"👩‍👩‍👦‍👦","name":"family: woman, woman, boy, boy","category":"Smileys & People","version":"2.0","skinTone":false,"keywords":["family:","woman,","boy,","boy"]}, + {"emoji":"👩‍👩‍👧‍👧","name":"family: woman, woman, girl, girl","category":"Smileys & People","version":"2.0","skinTone":false,"keywords":["family:","woman,","girl,","girl"]}, + {"emoji":"👨‍👦","name":"family: man, boy","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["family:","man,","boy"]}, + {"emoji":"👨‍👦‍👦","name":"family: man, boy, boy","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["family:","man,","boy,","boy"]}, + {"emoji":"👨‍👧","name":"family: man, girl","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["family:","man,","girl"]}, + {"emoji":"👨‍👧‍👦","name":"family: man, girl, boy","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["family:","man,","girl,","boy"]}, + {"emoji":"👨‍👧‍👧","name":"family: man, girl, girl","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["family:","man,","girl,","girl"]}, + {"emoji":"👩‍👦","name":"family: woman, boy","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["family:","woman,","boy"]}, + {"emoji":"👩‍👦‍👦","name":"family: woman, boy, boy","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["family:","woman,","boy,","boy"]}, + {"emoji":"👩‍👧","name":"family: woman, girl","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["family:","woman,","girl"]}, + {"emoji":"👩‍👧‍👦","name":"family: woman, girl, boy","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["family:","woman,","girl,","boy"]}, + {"emoji":"👩‍👧‍👧","name":"family: woman, girl, girl","category":"Smileys & People","version":"4.0","skinTone":false,"keywords":["family:","woman,","girl,","girl"]}, + {"emoji":"🗣️","name":"speaking head","category":"Smileys & People","version":"0.7","skinTone":false,"keywords":["speaking","head"]}, + {"emoji":"👤","name":"bust in silhouette","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["bust","in","silhouette","mysterious","shadow"]}, + {"emoji":"👥","name":"busts in silhouette","category":"Smileys & People","version":"1.0","skinTone":false,"keywords":["busts","in","silhouette","bff","bust","everyone","friend","friends","people"]}, + {"emoji":"🫂","name":"people hugging","category":"Smileys & People","version":"13.0","skinTone":false,"keywords":["people","hugging","comfort","embrace","farewell","friendship","goodbye","hello","hug","love","thanks"]}, + {"emoji":"👪","name":"family","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["family","child"]}, + {"emoji":"🧑‍🧑‍🧒","name":"family: adult, adult, child","category":"Smileys & People","version":"15.1","skinTone":false,"keywords":["family:","adult,","child","adult","family"]}, + {"emoji":"🧑‍🧑‍🧒‍🧒","name":"family: adult, adult, child, child","category":"Smileys & People","version":"15.1","skinTone":false,"keywords":["family:","adult,","child,","child","adult","family"]}, + {"emoji":"🧑‍🧒","name":"family: adult, child","category":"Smileys & People","version":"15.1","skinTone":false,"keywords":["family:","adult,","child","adult","family"]}, + {"emoji":"🧑‍🧒‍🧒","name":"family: adult, child, child","category":"Smileys & People","version":"15.1","skinTone":false,"keywords":["family:","adult,","child,","child","adult","family"]}, + {"emoji":"👣","name":"footprints","category":"Smileys & People","version":"0.6","skinTone":false,"keywords":["footprints","barefoot","clothing","footprint","omw","print","walk"]}, + {"emoji":"🫆","name":"fingerprint","category":"Smileys & People","version":"16.0","skinTone":false,"keywords":["fingerprint","clue","crime","detective","forensics","identity","mystery","print","safety","trace"]}, + {"emoji":"🐵","name":"monkey face","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["monkey","face","animal","banana"]}, + {"emoji":"🐒","name":"monkey","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["monkey","animal","banana"]}, + {"emoji":"🦍","name":"gorilla","category":"Animals & Nature","version":"3.0","skinTone":false,"keywords":["gorilla","animal"]}, + {"emoji":"🦧","name":"orangutan","category":"Animals & Nature","version":"12.0","skinTone":false,"keywords":["orangutan","animal","ape","monkey"]}, + {"emoji":"🐶","name":"dog face","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["dog","face","adorbs","animal","pet","puppies","puppy"]}, + {"emoji":"🐕","name":"dog","category":"Animals & Nature","version":"0.7","skinTone":false,"keywords":["dog","animal","animals","dogs","pet"]}, + {"emoji":"🦮","name":"guide dog","category":"Animals & Nature","version":"12.0","skinTone":false,"keywords":["guide","dog","accessibility","animal","blind"]}, + {"emoji":"🐕‍🦺","name":"service dog","category":"Animals & Nature","version":"12.0","skinTone":false,"keywords":["service","dog","accessibility","animal","assistance"]}, + {"emoji":"🐩","name":"poodle","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["poodle","animal","dog","fluffy"]}, + {"emoji":"🐺","name":"wolf","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["wolf","animal","face"]}, + {"emoji":"🦊","name":"fox","category":"Animals & Nature","version":"3.0","skinTone":false,"keywords":["fox","animal","face"]}, + {"emoji":"🦝","name":"raccoon","category":"Animals & Nature","version":"11.0","skinTone":false,"keywords":["raccoon","animal","curious","sly"]}, + {"emoji":"🐱","name":"cat face","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["cat","face","animal","kitten","kitty","pet"]}, + {"emoji":"🐈","name":"cat","category":"Animals & Nature","version":"0.7","skinTone":false,"keywords":["cat","animal","animals","cats","kitten","pet"]}, + {"emoji":"🐈‍⬛","name":"black cat","category":"Animals & Nature","version":"13.0","skinTone":false,"keywords":["black","cat","animal","feline","halloween","meow","unlucky"]}, + {"emoji":"🦁","name":"lion","category":"Animals & Nature","version":"1.0","skinTone":false,"keywords":["lion","alpha","animal","face","Leo","mane","order","rawr","roar","safari","strong","zodiac"]}, + {"emoji":"🐯","name":"tiger face","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["tiger","face","animal","big","cat","predator"]}, + {"emoji":"🐅","name":"tiger","category":"Animals & Nature","version":"1.0","skinTone":false,"keywords":["tiger","animal","big","cat","predator","zoo"]}, + {"emoji":"🐆","name":"leopard","category":"Animals & Nature","version":"1.0","skinTone":false,"keywords":["leopard","animal","big","cat","predator","zoo"]}, + {"emoji":"🐴","name":"horse face","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["horse","face","animal","dressage","equine","farm","horses"]}, + {"emoji":"🫎","name":"moose","category":"Animals & Nature","version":"15.0","skinTone":false,"keywords":["moose","alces","animal","antlers","elk","mammal"]}, + {"emoji":"🫏","name":"donkey","category":"Animals & Nature","version":"15.0","skinTone":false,"keywords":["donkey","animal","ass","burro","hinny","mammal","mule","stubborn"]}, + {"emoji":"🐎","name":"horse","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["horse","animal","equestrian","farm","racehorse","racing"]}, + {"emoji":"🦄","name":"unicorn","category":"Animals & Nature","version":"1.0","skinTone":false,"keywords":["unicorn","face"]}, + {"emoji":"🦓","name":"zebra","category":"Animals & Nature","version":"5.0","skinTone":false,"keywords":["zebra","animal","stripe"]}, + {"emoji":"🦌","name":"deer","category":"Animals & Nature","version":"3.0","skinTone":false,"keywords":["deer","animal"]}, + {"emoji":"🦬","name":"bison","category":"Animals & Nature","version":"13.0","skinTone":false,"keywords":["bison","animal","buffalo","herd","wisent"]}, + {"emoji":"🐮","name":"cow face","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["cow","face","animal","farm","milk","moo"]}, + {"emoji":"🐂","name":"ox","category":"Animals & Nature","version":"1.0","skinTone":false,"keywords":["ox","animal","animals","bull","farm","Taurus","zodiac"]}, + {"emoji":"🐃","name":"water buffalo","category":"Animals & Nature","version":"1.0","skinTone":false,"keywords":["water","buffalo","animal","zoo"]}, + {"emoji":"🐄","name":"cow","category":"Animals & Nature","version":"1.0","skinTone":false,"keywords":["cow","animal","animals","farm","milk","moo"]}, + {"emoji":"🐷","name":"pig face","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["pig","face","animal","bacon","farm","pork"]}, + {"emoji":"🐖","name":"pig","category":"Animals & Nature","version":"1.0","skinTone":false,"keywords":["pig","animal","bacon","farm","pork","sow"]}, + {"emoji":"🐗","name":"boar","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["boar","animal","pig"]}, + {"emoji":"🐽","name":"pig nose","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["pig","nose","animal","face","farm","smell","snout"]}, + {"emoji":"🐏","name":"ram","category":"Animals & Nature","version":"1.0","skinTone":false,"keywords":["ram","animal","Aries","horns","male","sheep","zodiac","zoo"]}, + {"emoji":"🐑","name":"ewe","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["ewe","animal","baa","farm","female","fluffy","lamb","sheep","wool"]}, + {"emoji":"🐐","name":"goat","category":"Animals & Nature","version":"1.0","skinTone":false,"keywords":["goat","animal","Capricorn","farm","milk","zodiac"]}, + {"emoji":"🐪","name":"camel","category":"Animals & Nature","version":"1.0","skinTone":false,"keywords":["camel","animal","desert","dromedary","hump","one"]}, + {"emoji":"🐫","name":"two-hump camel","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["two-hump","camel","animal","bactrian","desert","hump","two"]}, + {"emoji":"🦙","name":"llama","category":"Animals & Nature","version":"11.0","skinTone":false,"keywords":["llama","alpaca","animal","guanaco","vicuña","wool"]}, + {"emoji":"🦒","name":"giraffe","category":"Animals & Nature","version":"5.0","skinTone":false,"keywords":["giraffe","animal","spots"]}, + {"emoji":"🐘","name":"elephant","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["elephant","animal"]}, + {"emoji":"🦣","name":"mammoth","category":"Animals & Nature","version":"13.0","skinTone":false,"keywords":["mammoth","animal","extinction","large","tusk","wooly"]}, + {"emoji":"🦏","name":"rhinoceros","category":"Animals & Nature","version":"3.0","skinTone":false,"keywords":["rhinoceros","animal"]}, + {"emoji":"🦛","name":"hippopotamus","category":"Animals & Nature","version":"11.0","skinTone":false,"keywords":["hippopotamus","animal","hippo"]}, + {"emoji":"🐭","name":"mouse face","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["mouse","face","animal"]}, + {"emoji":"🐁","name":"mouse","category":"Animals & Nature","version":"1.0","skinTone":false,"keywords":["mouse","animal","animals"]}, + {"emoji":"🐀","name":"rat","category":"Animals & Nature","version":"1.0","skinTone":false,"keywords":["rat","animal"]}, + {"emoji":"🐹","name":"hamster","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["hamster","animal","face","pet"]}, + {"emoji":"🐰","name":"rabbit face","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["rabbit","face","animal","bunny","pet"]}, + {"emoji":"🐇","name":"rabbit","category":"Animals & Nature","version":"1.0","skinTone":false,"keywords":["rabbit","animal","bunny","pet"]}, + {"emoji":"🐿️","name":"chipmunk","category":"Animals & Nature","version":"0.7","skinTone":false,"keywords":["chipmunk"]}, + {"emoji":"🦫","name":"beaver","category":"Animals & Nature","version":"13.0","skinTone":false,"keywords":["beaver","animal","dam","teeth"]}, + {"emoji":"🦔","name":"hedgehog","category":"Animals & Nature","version":"5.0","skinTone":false,"keywords":["hedgehog","animal","spiny"]}, + {"emoji":"🦇","name":"bat","category":"Animals & Nature","version":"3.0","skinTone":false,"keywords":["bat","animal","vampire"]}, + {"emoji":"🐻","name":"bear","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["bear","animal","face","grizzly","growl","honey"]}, + {"emoji":"🐻‍❄️","name":"polar bear","category":"Animals & Nature","version":"13.0","skinTone":false,"keywords":["polar","bear"]}, + {"emoji":"🐨","name":"koala","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["koala","animal","australia","bear","down","face","marsupial","under"]}, + {"emoji":"🐼","name":"panda","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["panda","animal","bamboo","face"]}, + {"emoji":"🦥","name":"sloth","category":"Animals & Nature","version":"12.0","skinTone":false,"keywords":["sloth","lazy","slow"]}, + {"emoji":"🦦","name":"otter","category":"Animals & Nature","version":"12.0","skinTone":false,"keywords":["otter","animal","fishing","playful"]}, + {"emoji":"🦨","name":"skunk","category":"Animals & Nature","version":"12.0","skinTone":false,"keywords":["skunk","animal","stink"]}, + {"emoji":"🦘","name":"kangaroo","category":"Animals & Nature","version":"11.0","skinTone":false,"keywords":["kangaroo","animal","joey","jump","marsupial"]}, + {"emoji":"🦡","name":"badger","category":"Animals & Nature","version":"11.0","skinTone":false,"keywords":["badger","animal","honey","pester"]}, + {"emoji":"🐾","name":"paw prints","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["paw","prints","feet","paws","print"]}, + {"emoji":"🦃","name":"turkey","category":"Animals & Nature","version":"1.0","skinTone":false,"keywords":["turkey","bird","gobble","thanksgiving"]}, + {"emoji":"🐔","name":"chicken","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["chicken","animal","bird","ornithology"]}, + {"emoji":"🐓","name":"rooster","category":"Animals & Nature","version":"1.0","skinTone":false,"keywords":["rooster","animal","bird","ornithology"]}, + {"emoji":"🐣","name":"hatching chick","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["hatching","chick","animal","baby","bird","egg"]}, + {"emoji":"🐤","name":"baby chick","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["baby","chick","animal","bird","ornithology"]}, + {"emoji":"🐥","name":"front-facing baby chick","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["front-facing","baby","chick","animal","bird","newborn","ornithology"]}, + {"emoji":"🐦","name":"bird","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["bird","animal","ornithology"]}, + {"emoji":"🐧","name":"penguin","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["penguin","animal","antarctica","bird","ornithology"]}, + {"emoji":"🕊️","name":"dove","category":"Animals & Nature","version":"0.7","skinTone":false,"keywords":["dove"]}, + {"emoji":"🦅","name":"eagle","category":"Animals & Nature","version":"3.0","skinTone":false,"keywords":["eagle","animal","bird","ornithology"]}, + {"emoji":"🦆","name":"duck","category":"Animals & Nature","version":"3.0","skinTone":false,"keywords":["duck","animal","bird","ornithology"]}, + {"emoji":"🦢","name":"swan","category":"Animals & Nature","version":"11.0","skinTone":false,"keywords":["swan","animal","bird","cygnet","duckling","ornithology","ugly"]}, + {"emoji":"🦉","name":"owl","category":"Animals & Nature","version":"3.0","skinTone":false,"keywords":["owl","animal","bird","ornithology","wise"]}, + {"emoji":"🦤","name":"dodo","category":"Animals & Nature","version":"13.0","skinTone":false,"keywords":["dodo","animal","bird","extinction","large","ornithology"]}, + {"emoji":"🪶","name":"feather","category":"Animals & Nature","version":"13.0","skinTone":false,"keywords":["feather","bird","flight","light","plumage"]}, + {"emoji":"🦩","name":"flamingo","category":"Animals & Nature","version":"12.0","skinTone":false,"keywords":["flamingo","animal","bird","flamboyant","ornithology","tropical"]}, + {"emoji":"🦚","name":"peacock","category":"Animals & Nature","version":"11.0","skinTone":false,"keywords":["peacock","animal","bird","colorful","ornithology","ostentatious","peahen","pretty","proud"]}, + {"emoji":"🦜","name":"parrot","category":"Animals & Nature","version":"11.0","skinTone":false,"keywords":["parrot","animal","bird","ornithology","pirate","talk"]}, + {"emoji":"🪽","name":"wing","category":"Animals & Nature","version":"15.0","skinTone":false,"keywords":["wing","angelic","ascend","aviation","bird","fly","flying","heavenly","mythology","soar"]}, + {"emoji":"🐦‍⬛","name":"black bird","category":"Animals & Nature","version":"15.0","skinTone":false,"keywords":["black","bird","animal","beak","caw","corvid","crow","ornithology","raven","rook"]}, + {"emoji":"🪿","name":"goose","category":"Animals & Nature","version":"15.0","skinTone":false,"keywords":["goose","animal","bird","duck","flock","fowl","gaggle","gander","geese","honk","ornithology","silly"]}, + {"emoji":"🐦‍🔥","name":"phoenix","category":"Animals & Nature","version":"15.1","skinTone":false,"keywords":["phoenix","ascend","ascension","emerge","fantasy","firebird","glory","immortal","rebirth","reincarnation","reinvent","renewal","revival","revive","rise","transform"]}, + {"emoji":"🐸","name":"frog","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["frog","animal","face"]}, + {"emoji":"🐊","name":"crocodile","category":"Animals & Nature","version":"1.0","skinTone":false,"keywords":["crocodile","animal","zoo"]}, + {"emoji":"🐢","name":"turtle","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["turtle","animal","terrapin","tortoise"]}, + {"emoji":"🦎","name":"lizard","category":"Animals & Nature","version":"3.0","skinTone":false,"keywords":["lizard","animal","reptile"]}, + {"emoji":"🐍","name":"snake","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["snake","animal","bearer","Ophiuchus","serpent","zodiac"]}, + {"emoji":"🐲","name":"dragon face","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["dragon","face","animal","fairy","fairytale","tale"]}, + {"emoji":"🐉","name":"dragon","category":"Animals & Nature","version":"1.0","skinTone":false,"keywords":["dragon","animal","fairy","fairytale","knights","tale"]}, + {"emoji":"🦕","name":"sauropod","category":"Animals & Nature","version":"5.0","skinTone":false,"keywords":["sauropod","brachiosaurus","brontosaurus","dinosaur","diplodocus"]}, + {"emoji":"🦖","name":"T-Rex","category":"Animals & Nature","version":"5.0","skinTone":false,"keywords":["T-Rex","dinosaur","Rex","T","Tyrannosaurus"]}, + {"emoji":"🐳","name":"spouting whale","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["spouting","whale","animal","beach","face","ocean"]}, + {"emoji":"🐋","name":"whale","category":"Animals & Nature","version":"1.0","skinTone":false,"keywords":["whale","animal","beach","ocean"]}, + {"emoji":"🐬","name":"dolphin","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["dolphin","animal","beach","flipper","ocean"]}, + {"emoji":"🫍","name":"orca","category":"Animals & Nature","version":"17.0","skinTone":false,"keywords":["orca","marine","ocean","whale"]}, + {"emoji":"🦭","name":"seal","category":"Animals & Nature","version":"13.0","skinTone":false,"keywords":["seal","animal","lion","ocean","sea"]}, + {"emoji":"🐟","name":"fish","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["fish","animal","dinner","fishes","fishing","Pisces","zodiac"]}, + {"emoji":"🐠","name":"tropical fish","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["tropical","fish","animal","fishes"]}, + {"emoji":"🐡","name":"blowfish","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["blowfish","animal","fish"]}, + {"emoji":"🦈","name":"shark","category":"Animals & Nature","version":"3.0","skinTone":false,"keywords":["shark","animal","fish"]}, + {"emoji":"🐙","name":"octopus","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["octopus","animal","creature","ocean"]}, + {"emoji":"🐚","name":"spiral shell","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["spiral","shell","animal","beach","conch","sea"]}, + {"emoji":"🪸","name":"coral","category":"Animals & Nature","version":"14.0","skinTone":false,"keywords":["coral","change","climate","ocean","reef","sea"]}, + {"emoji":"🪼","name":"jellyfish","category":"Animals & Nature","version":"15.0","skinTone":false,"keywords":["jellyfish","animal","aquarium","burn","invertebrate","jelly","life","marine","ocean","ouch","plankton","sea","sting","stinger","tentacles"]}, + {"emoji":"🦀","name":"crab","category":"Animals & Nature","version":"1.0","skinTone":false,"keywords":["crab","Cancer","zodiac"]}, + {"emoji":"🦞","name":"lobster","category":"Animals & Nature","version":"11.0","skinTone":false,"keywords":["lobster","animal","bisque","claws","seafood"]}, + {"emoji":"🦐","name":"shrimp","category":"Animals & Nature","version":"3.0","skinTone":false,"keywords":["shrimp","food","shellfish","small"]}, + {"emoji":"🦑","name":"squid","category":"Animals & Nature","version":"3.0","skinTone":false,"keywords":["squid","animal","food","mollusk"]}, + {"emoji":"🦪","name":"oyster","category":"Animals & Nature","version":"12.0","skinTone":false,"keywords":["oyster","diving","pearl"]}, + {"emoji":"🐌","name":"snail","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["snail","animal","escargot","garden","nature","slug"]}, + {"emoji":"🦋","name":"butterfly","category":"Animals & Nature","version":"3.0","skinTone":false,"keywords":["butterfly","insect","pretty"]}, + {"emoji":"🐛","name":"bug","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["bug","animal","garden","insect"]}, + {"emoji":"🐜","name":"ant","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["ant","animal","garden","insect"]}, + {"emoji":"🐝","name":"honeybee","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["honeybee","animal","bee","bumblebee","honey","insect","nature","spring"]}, + {"emoji":"🪲","name":"beetle","category":"Animals & Nature","version":"13.0","skinTone":false,"keywords":["beetle","animal","bug","insect"]}, + {"emoji":"🐞","name":"lady beetle","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["lady","beetle","animal","garden","insect","ladybird","ladybug","nature"]}, + {"emoji":"🦗","name":"cricket","category":"Animals & Nature","version":"5.0","skinTone":false,"keywords":["cricket","animal","bug","grasshopper","insect","Orthoptera"]}, + {"emoji":"🪳","name":"cockroach","category":"Animals & Nature","version":"13.0","skinTone":false,"keywords":["cockroach","animal","insect","pest","roach"]}, + {"emoji":"🕷️","name":"spider","category":"Animals & Nature","version":"0.7","skinTone":false,"keywords":["spider"]}, + {"emoji":"🕸️","name":"spider web","category":"Animals & Nature","version":"0.7","skinTone":false,"keywords":["spider","web"]}, + {"emoji":"🦂","name":"scorpion","category":"Animals & Nature","version":"1.0","skinTone":false,"keywords":["scorpion","Scorpio","Scorpius","zodiac"]}, + {"emoji":"🦟","name":"mosquito","category":"Animals & Nature","version":"11.0","skinTone":false,"keywords":["mosquito","bite","disease","fever","insect","malaria","pest","virus"]}, + {"emoji":"🪰","name":"fly","category":"Animals & Nature","version":"13.0","skinTone":false,"keywords":["fly","animal","disease","insect","maggot","pest","rotting"]}, + {"emoji":"🪱","name":"worm","category":"Animals & Nature","version":"13.0","skinTone":false,"keywords":["worm","animal","annelid","earthworm","parasite"]}, + {"emoji":"🦠","name":"microbe","category":"Animals & Nature","version":"11.0","skinTone":false,"keywords":["microbe","amoeba","bacteria","science","virus"]}, + {"emoji":"💐","name":"bouquet","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["bouquet","anniversary","birthday","date","flower","love","plant","romance"]}, + {"emoji":"🌸","name":"cherry blossom","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["cherry","blossom","flower","plant","spring","springtime"]}, + {"emoji":"💮","name":"white flower","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["white","flower"]}, + {"emoji":"🪷","name":"lotus","category":"Animals & Nature","version":"14.0","skinTone":false,"keywords":["lotus","beauty","Buddhism","calm","flower","Hinduism","peace","purity","serenity"]}, + {"emoji":"🏵️","name":"rosette","category":"Animals & Nature","version":"0.7","skinTone":false,"keywords":["rosette"]}, + {"emoji":"🌹","name":"rose","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["rose","beauty","elegant","flower","love","plant","red","valentine"]}, + {"emoji":"🥀","name":"wilted flower","category":"Animals & Nature","version":"3.0","skinTone":false,"keywords":["wilted","flower","dying"]}, + {"emoji":"🌺","name":"hibiscus","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["hibiscus","flower","plant"]}, + {"emoji":"🌻","name":"sunflower","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["sunflower","flower","outdoors","plant","sun"]}, + {"emoji":"🌼","name":"blossom","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["blossom","buttercup","dandelion","flower","plant"]}, + {"emoji":"🌷","name":"tulip","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["tulip","blossom","flower","growth","plant"]}, + {"emoji":"🪻","name":"hyacinth","category":"Animals & Nature","version":"15.0","skinTone":false,"keywords":["hyacinth","bloom","bluebonnet","flower","indigo","lavender","lilac","lupine","plant","purple","shrub","snapdragon","spring","violet"]}, + {"emoji":"🌱","name":"seedling","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["seedling","plant","sapling","sprout","young"]}, + {"emoji":"🪴","name":"potted plant","category":"Animals & Nature","version":"13.0","skinTone":false,"keywords":["potted","plant","decor","grow","house","nurturing","pot"]}, + {"emoji":"🌲","name":"evergreen tree","category":"Animals & Nature","version":"1.0","skinTone":false,"keywords":["evergreen","tree","christmas","forest","pine"]}, + {"emoji":"🌳","name":"deciduous tree","category":"Animals & Nature","version":"1.0","skinTone":false,"keywords":["deciduous","tree","forest","green","habitat","shedding"]}, + {"emoji":"🌴","name":"palm tree","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["palm","tree","beach","plant","tropical"]}, + {"emoji":"🌵","name":"cactus","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["cactus","desert","drought","nature","plant"]}, + {"emoji":"🌾","name":"sheaf of rice","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["sheaf","of","rice","ear","grain","grains","plant"]}, + {"emoji":"🌿","name":"herb","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["herb","leaf","plant"]}, + {"emoji":"☘️","name":"shamrock","category":"Animals & Nature","version":"1.0","skinTone":false,"keywords":["shamrock"]}, + {"emoji":"🍀","name":"four leaf clover","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["four","leaf","clover","4","four-leaf","irish","lucky","plant"]}, + {"emoji":"🍁","name":"maple leaf","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["maple","leaf","falling"]}, + {"emoji":"🍂","name":"fallen leaf","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["fallen","leaf","autumn","fall","falling"]}, + {"emoji":"🍃","name":"leaf fluttering in wind","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["leaf","fluttering","in","wind","blow","flutter"]}, + {"emoji":"🪹","name":"empty nest","category":"Animals & Nature","version":"14.0","skinTone":false,"keywords":["empty","nest","branch","home","nesting"]}, + {"emoji":"🪺","name":"nest with eggs","category":"Animals & Nature","version":"14.0","skinTone":false,"keywords":["nest","with","eggs","bird","branch","egg","nesting"]}, + {"emoji":"🍄","name":"mushroom","category":"Animals & Nature","version":"0.6","skinTone":false,"keywords":["mushroom","fungus","toadstool"]}, + {"emoji":"🪾","name":"leafless tree","category":"Animals & Nature","version":"16.0","skinTone":false,"keywords":["leafless","tree","bare","barren","branches","dead","drought","trunk","winter","wood"]}, + {"emoji":"🍇","name":"grapes","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["grapes","Dionysus","fruit","grape"]}, + {"emoji":"🍈","name":"melon","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["melon","cantaloupe","fruit"]}, + {"emoji":"🍉","name":"watermelon","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["watermelon","fruit"]}, + {"emoji":"🍊","name":"tangerine","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["tangerine","c","citrus","fruit","nectarine","orange","vitamin"]}, + {"emoji":"🍋","name":"lemon","category":"Food & Drink","version":"1.0","skinTone":false,"keywords":["lemon","citrus","fruit","sour"]}, + {"emoji":"🍋‍🟩","name":"lime","category":"Food & Drink","version":"15.1","skinTone":false,"keywords":["lime","acidity","citrus","cocktail","fruit","garnish","key","margarita","mojito","refreshing","salsa","sour","tangy","tequila","tropical","zest"]}, + {"emoji":"🍌","name":"banana","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["banana","fruit","potassium"]}, + {"emoji":"🍍","name":"pineapple","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["pineapple","colada","fruit","pina","tropical"]}, + {"emoji":"🥭","name":"mango","category":"Food & Drink","version":"11.0","skinTone":false,"keywords":["mango","food","fruit","tropical"]}, + {"emoji":"🍎","name":"red apple","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["red","apple","diet","food","fruit","health","ripe"]}, + {"emoji":"🍏","name":"green apple","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["green","apple","fruit"]}, + {"emoji":"🍐","name":"pear","category":"Food & Drink","version":"1.0","skinTone":false,"keywords":["pear","fruit"]}, + {"emoji":"🍑","name":"peach","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["peach","fruit"]}, + {"emoji":"🍒","name":"cherries","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["cherries","berries","cherry","fruit","red"]}, + {"emoji":"🍓","name":"strawberry","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["strawberry","berry","fruit"]}, + {"emoji":"🫐","name":"blueberries","category":"Food & Drink","version":"13.0","skinTone":false,"keywords":["blueberries","berries","berry","bilberry","blue","blueberry","food","fruit"]}, + {"emoji":"🥝","name":"kiwi fruit","category":"Food & Drink","version":"3.0","skinTone":false,"keywords":["kiwi","fruit","food"]}, + {"emoji":"🍅","name":"tomato","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["tomato","food","fruit","vegetable"]}, + {"emoji":"🫒","name":"olive","category":"Food & Drink","version":"13.0","skinTone":false,"keywords":["olive","food"]}, + {"emoji":"🥥","name":"coconut","category":"Food & Drink","version":"5.0","skinTone":false,"keywords":["coconut","colada","palm","piña"]}, + {"emoji":"🥑","name":"avocado","category":"Food & Drink","version":"3.0","skinTone":false,"keywords":["avocado","food","fruit"]}, + {"emoji":"🍆","name":"eggplant","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["eggplant","aubergine","vegetable"]}, + {"emoji":"🥔","name":"potato","category":"Food & Drink","version":"3.0","skinTone":false,"keywords":["potato","food","vegetable"]}, + {"emoji":"🥕","name":"carrot","category":"Food & Drink","version":"3.0","skinTone":false,"keywords":["carrot","food","vegetable"]}, + {"emoji":"🌽","name":"ear of corn","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["ear","of","corn","crops","farm","maize","maze"]}, + {"emoji":"🌶️","name":"hot pepper","category":"Food & Drink","version":"0.7","skinTone":false,"keywords":["hot","pepper"]}, + {"emoji":"🫑","name":"bell pepper","category":"Food & Drink","version":"13.0","skinTone":false,"keywords":["bell","pepper","capsicum","food","vegetable"]}, + {"emoji":"🥒","name":"cucumber","category":"Food & Drink","version":"3.0","skinTone":false,"keywords":["cucumber","food","pickle","vegetable"]}, + {"emoji":"🥬","name":"leafy green","category":"Food & Drink","version":"11.0","skinTone":false,"keywords":["leafy","green","bok","burgers","cabbage","choy","kale","lettuce","salad"]}, + {"emoji":"🥦","name":"broccoli","category":"Food & Drink","version":"5.0","skinTone":false,"keywords":["broccoli","cabbage","wild"]}, + {"emoji":"🧄","name":"garlic","category":"Food & Drink","version":"12.0","skinTone":false,"keywords":["garlic","flavoring"]}, + {"emoji":"🧅","name":"onion","category":"Food & Drink","version":"12.0","skinTone":false,"keywords":["onion","flavoring"]}, + {"emoji":"🥜","name":"peanuts","category":"Food & Drink","version":"3.0","skinTone":false,"keywords":["peanuts","food","nut","peanut","vegetable"]}, + {"emoji":"🫘","name":"beans","category":"Food & Drink","version":"14.0","skinTone":false,"keywords":["beans","food","kidney","legume","small"]}, + {"emoji":"🌰","name":"chestnut","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["chestnut","almond","plant"]}, + {"emoji":"🫚","name":"ginger root","category":"Food & Drink","version":"15.0","skinTone":false,"keywords":["ginger","root","beer","health","herb","natural","spice"]}, + {"emoji":"🫛","name":"pea pod","category":"Food & Drink","version":"15.0","skinTone":false,"keywords":["pea","pod","beans","beanstalk","edamame","legume","soybean","vegetable","veggie"]}, + {"emoji":"🍄‍🟫","name":"brown mushroom","category":"Food & Drink","version":"15.1","skinTone":false,"keywords":["brown","mushroom","food","fungi","fungus","nature","pizza","portobello","shiitake","shroom","spore","sprout","toppings","truffle","vegetable","vegetarian","veggie"]}, + {"emoji":"🫜","name":"root vegetable","category":"Food & Drink","version":"16.0","skinTone":false,"keywords":["root","vegetable","beet","food","garden","radish","salad","turnip","vegetarian"]}, + {"emoji":"🍞","name":"bread","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["bread","carbs","food","grain","loaf","restaurant","toast","wheat"]}, + {"emoji":"🥐","name":"croissant","category":"Food & Drink","version":"3.0","skinTone":false,"keywords":["croissant","bread","breakfast","crescent","food","french","roll"]}, + {"emoji":"🥖","name":"baguette bread","category":"Food & Drink","version":"3.0","skinTone":false,"keywords":["baguette","bread","food","french"]}, + {"emoji":"🫓","name":"flatbread","category":"Food & Drink","version":"13.0","skinTone":false,"keywords":["flatbread","arepa","bread","food","gordita","lavash","naan","pita"]}, + {"emoji":"🥨","name":"pretzel","category":"Food & Drink","version":"5.0","skinTone":false,"keywords":["pretzel","convoluted","twisted"]}, + {"emoji":"🥯","name":"bagel","category":"Food & Drink","version":"11.0","skinTone":false,"keywords":["bagel","bakery","bread","breakfast","schmear"]}, + {"emoji":"🥞","name":"pancakes","category":"Food & Drink","version":"3.0","skinTone":false,"keywords":["pancakes","breakfast","crêpe","food","hotcake","pancake"]}, + {"emoji":"🧇","name":"waffle","category":"Food & Drink","version":"12.0","skinTone":false,"keywords":["waffle","breakfast","indecisive","iron"]}, + {"emoji":"🧀","name":"cheese wedge","category":"Food & Drink","version":"1.0","skinTone":false,"keywords":["cheese","wedge"]}, + {"emoji":"🍖","name":"meat on bone","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["meat","on","bone"]}, + {"emoji":"🍗","name":"poultry leg","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["poultry","leg","bone","chicken","drumstick","hungry","turkey"]}, + {"emoji":"🥩","name":"cut of meat","category":"Food & Drink","version":"5.0","skinTone":false,"keywords":["cut","of","meat","chop","lambchop","porkchop","red","steak"]}, + {"emoji":"🥓","name":"bacon","category":"Food & Drink","version":"3.0","skinTone":false,"keywords":["bacon","breakfast","food","meat"]}, + {"emoji":"🍔","name":"hamburger","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["hamburger","burger","eat","fast","food","hungry"]}, + {"emoji":"🍟","name":"french fries","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["french","fries","fast","food"]}, + {"emoji":"🍕","name":"pizza","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["pizza","cheese","food","hungry","pepperoni","slice"]}, + {"emoji":"🌭","name":"hot dog","category":"Food & Drink","version":"1.0","skinTone":false,"keywords":["hot","dog","frankfurter","hotdog","sausage"]}, + {"emoji":"🥪","name":"sandwich","category":"Food & Drink","version":"5.0","skinTone":false,"keywords":["sandwich","bread"]}, + {"emoji":"🌮","name":"taco","category":"Food & Drink","version":"1.0","skinTone":false,"keywords":["taco","mexican"]}, + {"emoji":"🌯","name":"burrito","category":"Food & Drink","version":"1.0","skinTone":false,"keywords":["burrito","mexican","wrap"]}, + {"emoji":"🫔","name":"tamale","category":"Food & Drink","version":"13.0","skinTone":false,"keywords":["tamale","food","mexican","pamonha","wrapped"]}, + {"emoji":"🥙","name":"stuffed flatbread","category":"Food & Drink","version":"3.0","skinTone":false,"keywords":["stuffed","flatbread","falafel","food","gyro","kebab"]}, + {"emoji":"🧆","name":"falafel","category":"Food & Drink","version":"12.0","skinTone":false,"keywords":["falafel","chickpea","meatball"]}, + {"emoji":"🥚","name":"egg","category":"Food & Drink","version":"3.0","skinTone":false,"keywords":["egg","breakfast","food"]}, + {"emoji":"🍳","name":"cooking","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["cooking","breakfast","easy","egg","fry","frying","over","pan","restaurant","side","sunny","up"]}, + {"emoji":"🥘","name":"shallow pan of food","category":"Food & Drink","version":"3.0","skinTone":false,"keywords":["shallow","pan","of","food","casserole","paella"]}, + {"emoji":"🍲","name":"pot of food","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["pot","of","food","soup","stew"]}, + {"emoji":"🫕","name":"fondue","category":"Food & Drink","version":"13.0","skinTone":false,"keywords":["fondue","cheese","chocolate","food","melted","pot","ski"]}, + {"emoji":"🥣","name":"bowl with spoon","category":"Food & Drink","version":"5.0","skinTone":false,"keywords":["bowl","with","spoon","breakfast","cereal","congee","oatmeal","porridge"]}, + {"emoji":"🥗","name":"green salad","category":"Food & Drink","version":"3.0","skinTone":false,"keywords":["green","salad","food"]}, + {"emoji":"🍿","name":"popcorn","category":"Food & Drink","version":"1.0","skinTone":false,"keywords":["popcorn","corn","movie","pop"]}, + {"emoji":"🧈","name":"butter","category":"Food & Drink","version":"12.0","skinTone":false,"keywords":["butter","dairy"]}, + {"emoji":"🧂","name":"salt","category":"Food & Drink","version":"11.0","skinTone":false,"keywords":["salt","condiment","flavor","mad","salty","shaker","taste","upset"]}, + {"emoji":"🥫","name":"canned food","category":"Food & Drink","version":"5.0","skinTone":false,"keywords":["canned","food","can"]}, + {"emoji":"🍱","name":"bento box","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["bento","box","food"]}, + {"emoji":"🍘","name":"rice cracker","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["rice","cracker","food"]}, + {"emoji":"🍙","name":"rice ball","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["rice","ball","food","Japanese"]}, + {"emoji":"🍚","name":"cooked rice","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["cooked","rice","food"]}, + {"emoji":"🍛","name":"curry rice","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["curry","rice","food"]}, + {"emoji":"🍜","name":"steaming bowl","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["steaming","bowl","chopsticks","food","noodle","pho","ramen","soup"]}, + {"emoji":"🍝","name":"spaghetti","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["spaghetti","food","meatballs","pasta","restaurant"]}, + {"emoji":"🍠","name":"roasted sweet potato","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["roasted","sweet","potato","food"]}, + {"emoji":"🍢","name":"oden","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["oden","food","kebab","restaurant","seafood","skewer","stick"]}, + {"emoji":"🍣","name":"sushi","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["sushi","food"]}, + {"emoji":"🍤","name":"fried shrimp","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["fried","shrimp","prawn","tempura"]}, + {"emoji":"🍥","name":"fish cake with swirl","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["fish","cake","with","swirl","food","pastry","restaurant"]}, + {"emoji":"🥮","name":"moon cake","category":"Food & Drink","version":"11.0","skinTone":false,"keywords":["moon","cake","autumn","festival","yuèbǐng"]}, + {"emoji":"🍡","name":"dango","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["dango","dessert","Japanese","skewer","stick","sweet"]}, + {"emoji":"🥟","name":"dumpling","category":"Food & Drink","version":"5.0","skinTone":false,"keywords":["dumpling","empanada","gyōza","jiaozi","pierogi","potsticker"]}, + {"emoji":"🥠","name":"fortune cookie","category":"Food & Drink","version":"5.0","skinTone":false,"keywords":["fortune","cookie","prophecy"]}, + {"emoji":"🥡","name":"takeout box","category":"Food & Drink","version":"5.0","skinTone":false,"keywords":["takeout","box","chopsticks","delivery","food","oyster","pail"]}, + {"emoji":"🍦","name":"soft ice cream","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["soft","ice","cream","dessert","food","icecream","restaurant","serve","sweet"]}, + {"emoji":"🍧","name":"shaved ice","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["shaved","ice","dessert","restaurant","sweet"]}, + {"emoji":"🍨","name":"ice cream","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["ice","cream","dessert","food","restaurant","sweet"]}, + {"emoji":"🍩","name":"doughnut","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["doughnut","breakfast","dessert","donut","food","sweet"]}, + {"emoji":"🍪","name":"cookie","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["cookie","chip","chocolate","dessert","sweet"]}, + {"emoji":"🎂","name":"birthday cake","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["birthday","cake","bday","celebration","dessert","happy","pastry","sweet"]}, + {"emoji":"🍰","name":"shortcake","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["shortcake","cake","dessert","pastry","slice","sweet"]}, + {"emoji":"🧁","name":"cupcake","category":"Food & Drink","version":"11.0","skinTone":false,"keywords":["cupcake","bakery","dessert","sprinkles","sugar","sweet","treat"]}, + {"emoji":"🥧","name":"pie","category":"Food & Drink","version":"5.0","skinTone":false,"keywords":["pie","apple","filling","fruit","meat","pastry","pumpkin","slice"]}, + {"emoji":"🍫","name":"chocolate bar","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["chocolate","bar","candy","dessert","halloween","sweet","tooth"]}, + {"emoji":"🍬","name":"candy","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["candy","cavities","dessert","halloween","restaurant","sweet","tooth","wrapper"]}, + {"emoji":"🍭","name":"lollipop","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["lollipop","candy","dessert","food","restaurant","sweet"]}, + {"emoji":"🍮","name":"custard","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["custard","dessert","pudding","sweet"]}, + {"emoji":"🍯","name":"honey pot","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["honey","pot","barrel","bear","food","honeypot","jar","sweet"]}, + {"emoji":"🍼","name":"baby bottle","category":"Food & Drink","version":"1.0","skinTone":false,"keywords":["baby","bottle","babies","birth","born","drink","infant","milk","newborn"]}, + {"emoji":"🥛","name":"glass of milk","category":"Food & Drink","version":"3.0","skinTone":false,"keywords":["glass","of","milk","drink"]}, + {"emoji":"☕","name":"hot beverage","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["hot","beverage","cafe","caffeine","chai","coffee","drink","morning","steaming","tea"]}, + {"emoji":"🫖","name":"teapot","category":"Food & Drink","version":"13.0","skinTone":false,"keywords":["teapot","brew","drink","food","pot","tea"]}, + {"emoji":"🍵","name":"teacup without handle","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["teacup","without","handle","beverage","cup","drink","oolong","tea"]}, + {"emoji":"🍶","name":"sake","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["sake","bar","beverage","bottle","cup","drink","restaurant"]}, + {"emoji":"🍾","name":"bottle with popping cork","category":"Food & Drink","version":"1.0","skinTone":false,"keywords":["bottle","with","popping","cork","bar","drink"]}, + {"emoji":"🍷","name":"wine glass","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["wine","glass","alcohol","bar","beverage","booze","club","drink","drinking","drinks","restaurant"]}, + {"emoji":"🍸","name":"cocktail glass","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["cocktail","glass","alcohol","bar","booze","club","drink","drinking","drinks","mad","martini","men"]}, + {"emoji":"🍹","name":"tropical drink","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["tropical","drink","alcohol","bar","booze","club","cocktail","drinking","drinks","drunk","mai","party","tai","tropics"]}, + {"emoji":"🍺","name":"beer mug","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["beer","mug","alcohol","ale","bar","booze","drink","drinking","drinks","octoberfest","oktoberfest","pint","stein","summer"]}, + {"emoji":"🍻","name":"clinking beer mugs","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["clinking","beer","mugs","alcohol","bar","booze","bottoms","cheers","clink","drinking","drinks"]}, + {"emoji":"🥂","name":"clinking glasses","category":"Food & Drink","version":"3.0","skinTone":false,"keywords":["clinking","glasses","celebrate","clink","drink","glass"]}, + {"emoji":"🥃","name":"tumbler glass","category":"Food & Drink","version":"3.0","skinTone":false,"keywords":["tumbler","glass","liquor","scotch","shot","whiskey","whisky"]}, + {"emoji":"🫗","name":"pouring liquid","category":"Food & Drink","version":"14.0","skinTone":false,"keywords":["pouring","liquid","accident","drink","empty","glass","oops","pour","spill","water"]}, + {"emoji":"🥤","name":"cup with straw","category":"Food & Drink","version":"5.0","skinTone":false,"keywords":["cup","with","straw","drink","juice","malt","soda","soft","water"]}, + {"emoji":"🧋","name":"bubble tea","category":"Food & Drink","version":"13.0","skinTone":false,"keywords":["bubble","tea","boba","food","milk","pearl"]}, + {"emoji":"🧃","name":"beverage box","category":"Food & Drink","version":"12.0","skinTone":false,"keywords":["beverage","box","juice","straw","sweet"]}, + {"emoji":"🧉","name":"mate","category":"Food & Drink","version":"12.0","skinTone":false,"keywords":["mate","drink"]}, + {"emoji":"🧊","name":"ice","category":"Food & Drink","version":"12.0","skinTone":false,"keywords":["ice","cold","cube","iceberg"]}, + {"emoji":"🥢","name":"chopsticks","category":"Food & Drink","version":"5.0","skinTone":false,"keywords":["chopsticks","hashi","jeotgarak","kuaizi"]}, + {"emoji":"🍽️","name":"fork and knife with plate","category":"Food & Drink","version":"0.7","skinTone":false,"keywords":["fork","and","knife","with","plate"]}, + {"emoji":"🍴","name":"fork and knife","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["fork","and","knife","breakfast","breaky","cooking","cutlery","delicious","dinner","eat","feed","food","hungry","lunch","restaurant","yum","yummy"]}, + {"emoji":"🥄","name":"spoon","category":"Food & Drink","version":"3.0","skinTone":false,"keywords":["spoon","eat","tableware"]}, + {"emoji":"🔪","name":"kitchen knife","category":"Food & Drink","version":"0.6","skinTone":false,"keywords":["kitchen","knife","chef","cooking","hocho","tool","weapon"]}, + {"emoji":"🫙","name":"jar","category":"Food & Drink","version":"14.0","skinTone":false,"keywords":["jar","condiment","container","empty","nothing","sauce","store"]}, + {"emoji":"🏺","name":"amphora","category":"Food & Drink","version":"1.0","skinTone":false,"keywords":["amphora","Aquarius","cooking","drink","jug","tool","weapon","zodiac"]}, + {"emoji":"🌍","name":"globe showing Europe-Africa","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["globe","showing","Europe-Africa","Africa","earth","Europe","world"]}, + {"emoji":"🌎","name":"globe showing Americas","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["globe","showing","Americas","earth","world"]}, + {"emoji":"🌏","name":"globe showing Asia-Australia","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["globe","showing","Asia-Australia","Asia","Australia","earth","world"]}, + {"emoji":"🌐","name":"globe with meridians","category":"Travel & Places","version":"1.0","skinTone":false,"keywords":["globe","with","meridians","earth","internet","web","world","worldwide"]}, + {"emoji":"🗺️","name":"world map","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["world","map"]}, + {"emoji":"🗾","name":"map of Japan","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["map","of","Japan"]}, + {"emoji":"🧭","name":"compass","category":"Travel & Places","version":"11.0","skinTone":false,"keywords":["compass","direction","magnetic","navigation","orienteering"]}, + {"emoji":"🏔️","name":"snow-capped mountain","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["snow-capped","mountain"]}, + {"emoji":"⛰️","name":"mountain","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["mountain"]}, + {"emoji":"🛘","name":"landslide","category":"Travel & Places","version":"17.0","skinTone":false,"keywords":["landslide","avalanche","danger","disaster","earthquake","mountain","mudslide","rocks"]}, + {"emoji":"🌋","name":"volcano","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["volcano","eruption","mountain","nature"]}, + {"emoji":"🗻","name":"mount fuji","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["mount","fuji","mountain","nature"]}, + {"emoji":"🏕️","name":"camping","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["camping"]}, + {"emoji":"🏖️","name":"beach with umbrella","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["beach","with","umbrella"]}, + {"emoji":"🏜️","name":"desert","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["desert"]}, + {"emoji":"🏝️","name":"desert island","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["desert","island"]}, + {"emoji":"🏞️","name":"national park","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["national","park"]}, + {"emoji":"🏟️","name":"stadium","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["stadium"]}, + {"emoji":"🏛️","name":"classical building","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["classical","building"]}, + {"emoji":"🏗️","name":"building construction","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["building","construction"]}, + {"emoji":"🧱","name":"brick","category":"Travel & Places","version":"11.0","skinTone":false,"keywords":["brick","bricks","clay","mortar","wall"]}, + {"emoji":"🪨","name":"rock","category":"Travel & Places","version":"13.0","skinTone":false,"keywords":["rock","boulder","heavy","solid","stone","tough"]}, + {"emoji":"🪵","name":"wood","category":"Travel & Places","version":"13.0","skinTone":false,"keywords":["wood","log","lumber","timber"]}, + {"emoji":"🛖","name":"hut","category":"Travel & Places","version":"13.0","skinTone":false,"keywords":["hut","home","house","roundhouse","shelter","yurt"]}, + {"emoji":"🏘️","name":"houses","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["houses"]}, + {"emoji":"🏚️","name":"derelict house","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["derelict","house"]}, + {"emoji":"🏠","name":"house","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["house","building","country","heart","home","ranch","settle","simple","suburban","suburbia","where"]}, + {"emoji":"🏡","name":"house with garden","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["house","with","garden","building","country","heart","home","ranch","settle","simple","suburban","suburbia","where"]}, + {"emoji":"🏢","name":"office building","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["office","building","city","cubical","job"]}, + {"emoji":"🏣","name":"Japanese post office","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["Japanese","post","office","building"]}, + {"emoji":"🏤","name":"post office","category":"Travel & Places","version":"1.0","skinTone":false,"keywords":["post","office","building","European"]}, + {"emoji":"🏥","name":"hospital","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["hospital","building","doctor","medicine"]}, + {"emoji":"🏦","name":"bank","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["bank","building"]}, + {"emoji":"🏨","name":"hotel","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["hotel","building"]}, + {"emoji":"🏩","name":"love hotel","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["love","hotel","building"]}, + {"emoji":"🏪","name":"convenience store","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["convenience","store","24","building","hours"]}, + {"emoji":"🏫","name":"school","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["school","building"]}, + {"emoji":"🏬","name":"department store","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["department","store","building"]}, + {"emoji":"🏭","name":"factory","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["factory","building"]}, + {"emoji":"🏯","name":"Japanese castle","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["Japanese","castle","building"]}, + {"emoji":"🏰","name":"castle","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["castle","building","European"]}, + {"emoji":"💒","name":"wedding","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["wedding","chapel","hitched","nuptials","romance"]}, + {"emoji":"🗼","name":"Tokyo tower","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["Tokyo","tower"]}, + {"emoji":"🗽","name":"Statue of Liberty","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["Statue","of","Liberty","liberty","new","ny","nyc","statue","york"]}, + {"emoji":"⛪","name":"church","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["church","bless","chapel","Christian","cross","religion"]}, + {"emoji":"🕌","name":"mosque","category":"Travel & Places","version":"1.0","skinTone":false,"keywords":["mosque","islam","masjid","Muslim","religion"]}, + {"emoji":"🛕","name":"hindu temple","category":"Travel & Places","version":"12.0","skinTone":false,"keywords":["hindu","temple"]}, + {"emoji":"🕍","name":"synagogue","category":"Travel & Places","version":"1.0","skinTone":false,"keywords":["synagogue","Jew","Jewish","judaism","religion","temple"]}, + {"emoji":"⛩️","name":"shinto shrine","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["shinto","shrine"]}, + {"emoji":"🕋","name":"kaaba","category":"Travel & Places","version":"1.0","skinTone":false,"keywords":["kaaba","hajj","islam","Muslim","religion","umrah"]}, + {"emoji":"⛲","name":"fountain","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["fountain"]}, + {"emoji":"⛺","name":"tent","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["tent","camping"]}, + {"emoji":"🌁","name":"foggy","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["foggy","fog"]}, + {"emoji":"🌃","name":"night with stars","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["night","with","stars","star"]}, + {"emoji":"🏙️","name":"cityscape","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["cityscape"]}, + {"emoji":"🌄","name":"sunrise over mountains","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["sunrise","over","mountains","morning","sun"]}, + {"emoji":"🌅","name":"sunrise","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["sunrise","morning","nature","sun"]}, + {"emoji":"🌆","name":"cityscape at dusk","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["cityscape","at","dusk","building","city","evening","landscape","sun","sunset"]}, + {"emoji":"🌇","name":"sunset","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["sunset","building","dusk","sun"]}, + {"emoji":"🌉","name":"bridge at night","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["bridge","at","night"]}, + {"emoji":"♨️","name":"hot springs","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["hot","springs"]}, + {"emoji":"🎠","name":"carousel horse","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["carousel","horse","entertainment"]}, + {"emoji":"🛝","name":"playground slide","category":"Travel & Places","version":"14.0","skinTone":false,"keywords":["playground","slide","amusement","park","play","playing","sliding","theme"]}, + {"emoji":"🎡","name":"ferris wheel","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["ferris","wheel","amusement","park","theme"]}, + {"emoji":"🎢","name":"roller coaster","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["roller","coaster","amusement","park","theme"]}, + {"emoji":"💈","name":"barber pole","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["barber","pole","cut","fresh","haircut","shave"]}, + {"emoji":"🎪","name":"circus tent","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["circus","tent"]}, + {"emoji":"🚂","name":"locomotive","category":"Travel & Places","version":"1.0","skinTone":false,"keywords":["locomotive","caboose","engine","railway","steam","train","trains","travel"]}, + {"emoji":"🚃","name":"railway car","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["railway","car","electric","train","tram","travel","trolleybus"]}, + {"emoji":"🚄","name":"high-speed train","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["high-speed","train","railway","shinkansen","speed"]}, + {"emoji":"🚅","name":"bullet train","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["bullet","train","high-speed","nose","railway","shinkansen","speed","travel"]}, + {"emoji":"🚆","name":"train","category":"Travel & Places","version":"1.0","skinTone":false,"keywords":["train","arrived","choo","railway"]}, + {"emoji":"🚇","name":"metro","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["metro","subway","travel"]}, + {"emoji":"🚈","name":"light rail","category":"Travel & Places","version":"1.0","skinTone":false,"keywords":["light","rail","arrived","monorail","railway"]}, + {"emoji":"🚉","name":"station","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["station","railway","train"]}, + {"emoji":"🚊","name":"tram","category":"Travel & Places","version":"1.0","skinTone":false,"keywords":["tram","trolleybus"]}, + {"emoji":"🚝","name":"monorail","category":"Travel & Places","version":"1.0","skinTone":false,"keywords":["monorail","vehicle"]}, + {"emoji":"🚞","name":"mountain railway","category":"Travel & Places","version":"1.0","skinTone":false,"keywords":["mountain","railway","car","trip"]}, + {"emoji":"🚋","name":"tram car","category":"Travel & Places","version":"1.0","skinTone":false,"keywords":["tram","car","bus","trolley","trolleybus"]}, + {"emoji":"🚌","name":"bus","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["bus","school","vehicle"]}, + {"emoji":"🚍","name":"oncoming bus","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["oncoming","bus","cars"]}, + {"emoji":"🚎","name":"trolleybus","category":"Travel & Places","version":"1.0","skinTone":false,"keywords":["trolleybus","bus","tram","trolley"]}, + {"emoji":"🚐","name":"minibus","category":"Travel & Places","version":"1.0","skinTone":false,"keywords":["minibus","bus","drive","van","vehicle"]}, + {"emoji":"🚑","name":"ambulance","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["ambulance","emergency","vehicle"]}, + {"emoji":"🚒","name":"fire engine","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["fire","engine","truck"]}, + {"emoji":"🚓","name":"police car","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["police","car","5–0","cops","patrol"]}, + {"emoji":"🚔","name":"oncoming police car","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["oncoming","police","car"]}, + {"emoji":"🚕","name":"taxi","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["taxi","cab","cabbie","car","drive","vehicle","yellow"]}, + {"emoji":"🚖","name":"oncoming taxi","category":"Travel & Places","version":"1.0","skinTone":false,"keywords":["oncoming","taxi","cab","cabbie","cars","drove","hail","yellow"]}, + {"emoji":"🚗","name":"automobile","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["automobile","car","driving","vehicle"]}, + {"emoji":"🚘","name":"oncoming automobile","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["oncoming","automobile","car","cars","drove","vehicle"]}, + {"emoji":"🚙","name":"sport utility vehicle","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["sport","utility","vehicle","car","drive","recreational","sportutility"]}, + {"emoji":"🛻","name":"pickup truck","category":"Travel & Places","version":"13.0","skinTone":false,"keywords":["pickup","truck","automobile","car","flatbed","pick-up","transportation"]}, + {"emoji":"🚚","name":"delivery truck","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["delivery","truck","car","drive","vehicle"]}, + {"emoji":"🚛","name":"articulated lorry","category":"Travel & Places","version":"1.0","skinTone":false,"keywords":["articulated","lorry","car","drive","move","semi","truck","vehicle"]}, + {"emoji":"🚜","name":"tractor","category":"Travel & Places","version":"1.0","skinTone":false,"keywords":["tractor","vehicle"]}, + {"emoji":"🏎️","name":"racing car","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["racing","car"]}, + {"emoji":"🏍️","name":"motorcycle","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["motorcycle"]}, + {"emoji":"🛵","name":"motor scooter","category":"Travel & Places","version":"3.0","skinTone":false,"keywords":["motor","scooter"]}, + {"emoji":"🦽","name":"manual wheelchair","category":"Travel & Places","version":"12.0","skinTone":false,"keywords":["manual","wheelchair","accessibility"]}, + {"emoji":"🦼","name":"motorized wheelchair","category":"Travel & Places","version":"12.0","skinTone":false,"keywords":["motorized","wheelchair","accessibility"]}, + {"emoji":"🛺","name":"auto rickshaw","category":"Travel & Places","version":"12.0","skinTone":false,"keywords":["auto","rickshaw","tuk"]}, + {"emoji":"🚲","name":"bicycle","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["bicycle","bike","class","cycle","cycling","cyclist","gang","ride","spin","spinning"]}, + {"emoji":"🛴","name":"kick scooter","category":"Travel & Places","version":"3.0","skinTone":false,"keywords":["kick","scooter"]}, + {"emoji":"🛹","name":"skateboard","category":"Travel & Places","version":"11.0","skinTone":false,"keywords":["skateboard","board","skate","skater","wheels"]}, + {"emoji":"🛼","name":"roller skate","category":"Travel & Places","version":"13.0","skinTone":false,"keywords":["roller","skate","blades","skates","sport"]}, + {"emoji":"🚏","name":"bus stop","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["bus","stop","busstop"]}, + {"emoji":"🛣️","name":"motorway","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["motorway"]}, + {"emoji":"🛤️","name":"railway track","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["railway","track"]}, + {"emoji":"🛢️","name":"oil drum","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["oil","drum"]}, + {"emoji":"⛽","name":"fuel pump","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["fuel","pump","diesel","fuelpump","gas","gasoline","station"]}, + {"emoji":"🛞","name":"wheel","category":"Travel & Places","version":"14.0","skinTone":false,"keywords":["wheel","car","circle","tire","turn","vehicle"]}, + {"emoji":"🚨","name":"police car light","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["police","car","light","alarm","alert","beacon","emergency","revolving","siren"]}, + {"emoji":"🚥","name":"horizontal traffic light","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["horizontal","traffic","light","intersection","signal","stop","stoplight"]}, + {"emoji":"🚦","name":"vertical traffic light","category":"Travel & Places","version":"1.0","skinTone":false,"keywords":["vertical","traffic","light","drove","intersection","signal","stop","stoplight"]}, + {"emoji":"🛑","name":"stop sign","category":"Travel & Places","version":"3.0","skinTone":false,"keywords":["stop","sign","octagonal"]}, + {"emoji":"🚧","name":"construction","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["construction","barrier"]}, + {"emoji":"⚓","name":"anchor","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["anchor","ship","tool"]}, + {"emoji":"🛟","name":"ring buoy","category":"Travel & Places","version":"14.0","skinTone":false,"keywords":["ring","buoy","float","life","lifesaver","preserver","rescue","safety","save","saver","swim"]}, + {"emoji":"⛵","name":"sailboat","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["sailboat","boat","resort","sailing","sea","yacht"]}, + {"emoji":"🛶","name":"canoe","category":"Travel & Places","version":"3.0","skinTone":false,"keywords":["canoe","boat"]}, + {"emoji":"🚤","name":"speedboat","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["speedboat","billionaire","boat","lake","luxury","millionaire","summer","travel"]}, + {"emoji":"🛳️","name":"passenger ship","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["passenger","ship"]}, + {"emoji":"⛴️","name":"ferry","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["ferry"]}, + {"emoji":"🛥️","name":"motor boat","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["motor","boat"]}, + {"emoji":"🚢","name":"ship","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["ship","boat","passenger","travel"]}, + {"emoji":"✈️","name":"airplane","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["airplane"]}, + {"emoji":"🛩️","name":"small airplane","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["small","airplane"]}, + {"emoji":"🛫","name":"airplane departure","category":"Travel & Places","version":"1.0","skinTone":false,"keywords":["airplane","departure","aeroplane","check-in","departures","plane"]}, + {"emoji":"🛬","name":"airplane arrival","category":"Travel & Places","version":"1.0","skinTone":false,"keywords":["airplane","arrival","aeroplane","arrivals","arriving","landing","plane"]}, + {"emoji":"🪂","name":"parachute","category":"Travel & Places","version":"12.0","skinTone":false,"keywords":["parachute","hang-glide","parasail","skydive"]}, + {"emoji":"💺","name":"seat","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["seat","chair"]}, + {"emoji":"🚁","name":"helicopter","category":"Travel & Places","version":"1.0","skinTone":false,"keywords":["helicopter","copter","roflcopter","travel","vehicle"]}, + {"emoji":"🚟","name":"suspension railway","category":"Travel & Places","version":"1.0","skinTone":false,"keywords":["suspension","railway"]}, + {"emoji":"🚠","name":"mountain cableway","category":"Travel & Places","version":"1.0","skinTone":false,"keywords":["mountain","cableway","cable","gondola","lift","ski"]}, + {"emoji":"🚡","name":"aerial tramway","category":"Travel & Places","version":"1.0","skinTone":false,"keywords":["aerial","tramway","cable","car","gondola","ropeway"]}, + {"emoji":"🛰️","name":"satellite","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["satellite"]}, + {"emoji":"🚀","name":"rocket","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["rocket","launch","rockets","space","travel"]}, + {"emoji":"🛸","name":"flying saucer","category":"Travel & Places","version":"5.0","skinTone":false,"keywords":["flying","saucer","aliens","extra","terrestrial","UFO"]}, + {"emoji":"🛎️","name":"bellhop bell","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["bellhop","bell"]}, + {"emoji":"🧳","name":"luggage","category":"Travel & Places","version":"11.0","skinTone":false,"keywords":["luggage","bag","packing","roller","suitcase","travel"]}, + {"emoji":"⌛","name":"hourglass done","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["hourglass","done","sand","time","timer"]}, + {"emoji":"⏳","name":"hourglass not done","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["hourglass","not","done","flowing","hours","sand","timer","waiting","yolo"]}, + {"emoji":"⌚","name":"watch","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["watch","clock","time"]}, + {"emoji":"⏰","name":"alarm clock","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["alarm","clock","hours","hrs","late","time","waiting"]}, + {"emoji":"⏱️","name":"stopwatch","category":"Travel & Places","version":"1.0","skinTone":false,"keywords":["stopwatch"]}, + {"emoji":"⏲️","name":"timer clock","category":"Travel & Places","version":"1.0","skinTone":false,"keywords":["timer","clock"]}, + {"emoji":"🕰️","name":"mantelpiece clock","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["mantelpiece","clock"]}, + {"emoji":"🕛","name":"twelve o’clock","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["twelve","o’clock","12","12:00","clock","time"]}, + {"emoji":"🕧","name":"twelve-thirty","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["twelve-thirty","12","12:30","30","clock","thirty","time","twelve"]}, + {"emoji":"🕐","name":"one o’clock","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["one","o’clock","1","1:00","clock","time"]}, + {"emoji":"🕜","name":"one-thirty","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["one-thirty","1","1:30","30","clock","one","thirty","time"]}, + {"emoji":"🕑","name":"two o’clock","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["two","o’clock","2","2:00","clock","time"]}, + {"emoji":"🕝","name":"two-thirty","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["two-thirty","2","2:30","30","clock","thirty","time","two"]}, + {"emoji":"🕒","name":"three o’clock","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["three","o’clock","3","3:00","clock","time"]}, + {"emoji":"🕞","name":"three-thirty","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["three-thirty","3","3:30","30","clock","thirty","three","time"]}, + {"emoji":"🕓","name":"four o’clock","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["four","o’clock","4","4:00","clock","time"]}, + {"emoji":"🕟","name":"four-thirty","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["four-thirty","30","4","4:30","clock","four","thirty","time"]}, + {"emoji":"🕔","name":"five o’clock","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["five","o’clock","5","5:00","clock","time"]}, + {"emoji":"🕠","name":"five-thirty","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["five-thirty","30","5","5:30","clock","five","thirty","time"]}, + {"emoji":"🕕","name":"six o’clock","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["six","o’clock","6","6:00","clock","time"]}, + {"emoji":"🕡","name":"six-thirty","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["six-thirty","30","6","6:30","clock","six","thirty"]}, + {"emoji":"🕖","name":"seven o’clock","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["seven","o’clock","0","7","7:00","clock"]}, + {"emoji":"🕢","name":"seven-thirty","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["seven-thirty","30","7","7:30","clock","seven","thirty"]}, + {"emoji":"🕗","name":"eight o’clock","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["eight","o’clock","8","8:00","clock","time"]}, + {"emoji":"🕣","name":"eight-thirty","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["eight-thirty","30","8","8:30","clock","eight","thirty","time"]}, + {"emoji":"🕘","name":"nine o’clock","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["nine","o’clock","9","9:00","clock","time"]}, + {"emoji":"🕤","name":"nine-thirty","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["nine-thirty","30","9","9:30","clock","nine","thirty","time"]}, + {"emoji":"🕙","name":"ten o’clock","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["ten","o’clock","0","10","10:00","clock"]}, + {"emoji":"🕥","name":"ten-thirty","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["ten-thirty","10","10:30","30","clock","ten","thirty","time"]}, + {"emoji":"🕚","name":"eleven o’clock","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["eleven","o’clock","11","11:00","clock","time"]}, + {"emoji":"🕦","name":"eleven-thirty","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["eleven-thirty","11","11:30","30","clock","eleven","thirty","time"]}, + {"emoji":"🌑","name":"new moon","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["new","moon","dark","space"]}, + {"emoji":"🌒","name":"waxing crescent moon","category":"Travel & Places","version":"1.0","skinTone":false,"keywords":["waxing","crescent","moon","dreams","space"]}, + {"emoji":"🌓","name":"first quarter moon","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["first","quarter","moon","space"]}, + {"emoji":"🌔","name":"waxing gibbous moon","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["waxing","gibbous","moon","space"]}, + {"emoji":"🌕","name":"full moon","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["full","moon","space"]}, + {"emoji":"🌖","name":"waning gibbous moon","category":"Travel & Places","version":"1.0","skinTone":false,"keywords":["waning","gibbous","moon","space"]}, + {"emoji":"🌗","name":"last quarter moon","category":"Travel & Places","version":"1.0","skinTone":false,"keywords":["last","quarter","moon","space"]}, + {"emoji":"🌘","name":"waning crescent moon","category":"Travel & Places","version":"1.0","skinTone":false,"keywords":["waning","crescent","moon","space"]}, + {"emoji":"🌙","name":"crescent moon","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["crescent","moon","ramadan","space"]}, + {"emoji":"🌚","name":"new moon face","category":"Travel & Places","version":"1.0","skinTone":false,"keywords":["new","moon","face","space"]}, + {"emoji":"🌛","name":"first quarter moon face","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["first","quarter","moon","face","space"]}, + {"emoji":"🌜","name":"last quarter moon face","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["last","quarter","moon","face","dreams"]}, + {"emoji":"🌡️","name":"thermometer","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["thermometer"]}, + {"emoji":"☀️","name":"sun","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["sun"]}, + {"emoji":"🌝","name":"full moon face","category":"Travel & Places","version":"1.0","skinTone":false,"keywords":["full","moon","face","bright"]}, + {"emoji":"🌞","name":"sun with face","category":"Travel & Places","version":"1.0","skinTone":false,"keywords":["sun","with","face","beach","bright","day","heat","shine","sunny","sunshine","weather"]}, + {"emoji":"🪐","name":"ringed planet","category":"Travel & Places","version":"12.0","skinTone":false,"keywords":["ringed","planet","saturn","saturnine"]}, + {"emoji":"⭐","name":"star","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["star","astronomy","medium","stars","white"]}, + {"emoji":"🌟","name":"glowing star","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["glowing","star","glittery","glow","night","shining","sparkle","win"]}, + {"emoji":"🌠","name":"shooting star","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["shooting","star","falling","night","space"]}, + {"emoji":"🌌","name":"milky way","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["milky","way","space"]}, + {"emoji":"☁️","name":"cloud","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["cloud"]}, + {"emoji":"⛅","name":"sun behind cloud","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["sun","behind","cloud","cloudy","weather"]}, + {"emoji":"⛈️","name":"cloud with lightning and rain","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["cloud","with","lightning","and","rain"]}, + {"emoji":"🌤️","name":"sun behind small cloud","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["sun","behind","small","cloud"]}, + {"emoji":"🌥️","name":"sun behind large cloud","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["sun","behind","large","cloud"]}, + {"emoji":"🌦️","name":"sun behind rain cloud","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["sun","behind","rain","cloud"]}, + {"emoji":"🌧️","name":"cloud with rain","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["cloud","with","rain"]}, + {"emoji":"🌨️","name":"cloud with snow","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["cloud","with","snow"]}, + {"emoji":"🌩️","name":"cloud with lightning","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["cloud","with","lightning"]}, + {"emoji":"🌪️","name":"tornado","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["tornado"]}, + {"emoji":"🌫️","name":"fog","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["fog"]}, + {"emoji":"🌬️","name":"wind face","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["wind","face"]}, + {"emoji":"🌀","name":"cyclone","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["cyclone","dizzy","hurricane","twister","typhoon","weather"]}, + {"emoji":"🌈","name":"rainbow","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["rainbow","gay","genderqueer","glbt","glbtq","lesbian","lgbt","lgbtq","lgbtqia","nature","pride","queer","rain","trans","transgender","weather"]}, + {"emoji":"🌂","name":"closed umbrella","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["closed","umbrella","clothing","rain"]}, + {"emoji":"☂️","name":"umbrella","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["umbrella"]}, + {"emoji":"☔","name":"umbrella with rain drops","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["umbrella","with","rain","drops","clothing","drop","weather"]}, + {"emoji":"⛱️","name":"umbrella on ground","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["umbrella","on","ground"]}, + {"emoji":"⚡","name":"high voltage","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["high","voltage","danger","electric","electricity","lightning","nature","thunder","thunderbolt","zap"]}, + {"emoji":"❄️","name":"snowflake","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["snowflake"]}, + {"emoji":"☃️","name":"snowman","category":"Travel & Places","version":"0.7","skinTone":false,"keywords":["snowman"]}, + {"emoji":"⛄","name":"snowman without snow","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["snowman","without","snow","cold","man"]}, + {"emoji":"☄️","name":"comet","category":"Travel & Places","version":"1.0","skinTone":false,"keywords":["comet"]}, + {"emoji":"🔥","name":"fire","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["fire","af","burn","flame","hot","lit","litaf","tool"]}, + {"emoji":"💧","name":"droplet","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["droplet","cold","comic","drop","nature","sad","sweat","tear","water","weather"]}, + {"emoji":"🌊","name":"water wave","category":"Travel & Places","version":"0.6","skinTone":false,"keywords":["water","wave","nature","ocean","surf","surfer","surfing"]}, + {"emoji":"🎃","name":"jack-o-lantern","category":"Activities","version":"0.6","skinTone":false,"keywords":["jack-o-lantern","celebration","halloween","jack","lantern","pumpkin"]}, + {"emoji":"🎄","name":"Christmas tree","category":"Activities","version":"0.6","skinTone":false,"keywords":["Christmas","tree","celebration"]}, + {"emoji":"🎆","name":"fireworks","category":"Activities","version":"0.6","skinTone":false,"keywords":["fireworks","boom","celebration","entertainment","yolo"]}, + {"emoji":"🎇","name":"sparkler","category":"Activities","version":"0.6","skinTone":false,"keywords":["sparkler","boom","celebration","fireworks","sparkle"]}, + {"emoji":"🧨","name":"firecracker","category":"Activities","version":"11.0","skinTone":false,"keywords":["firecracker","dynamite","explosive","fire","fireworks","light","pop","popping","spark"]}, + {"emoji":"✨","name":"sparkles","category":"Activities","version":"0.6","skinTone":false,"keywords":["sparkles","*","magic","sparkle","star"]}, + {"emoji":"🎈","name":"balloon","category":"Activities","version":"0.6","skinTone":false,"keywords":["balloon","birthday","celebrate","celebration"]}, + {"emoji":"🎉","name":"party popper","category":"Activities","version":"0.6","skinTone":false,"keywords":["party","popper","awesome","birthday","celebrate","celebration","excited","hooray","tada","woohoo"]}, + {"emoji":"🎊","name":"confetti ball","category":"Activities","version":"0.6","skinTone":false,"keywords":["confetti","ball","celebrate","celebration","party","woohoo"]}, + {"emoji":"🎋","name":"tanabata tree","category":"Activities","version":"0.6","skinTone":false,"keywords":["tanabata","tree","banner","celebration","Japanese"]}, + {"emoji":"🎍","name":"pine decoration","category":"Activities","version":"0.6","skinTone":false,"keywords":["pine","decoration","bamboo","celebration","Japanese","plant"]}, + {"emoji":"🎎","name":"Japanese dolls","category":"Activities","version":"0.6","skinTone":false,"keywords":["Japanese","dolls","celebration","doll","festival"]}, + {"emoji":"🎏","name":"carp streamer","category":"Activities","version":"0.6","skinTone":false,"keywords":["carp","streamer","celebration"]}, + {"emoji":"🎐","name":"wind chime","category":"Activities","version":"0.6","skinTone":false,"keywords":["wind","chime","bell","celebration"]}, + {"emoji":"🎑","name":"moon viewing ceremony","category":"Activities","version":"0.6","skinTone":false,"keywords":["moon","viewing","ceremony","celebration"]}, + {"emoji":"🧧","name":"red envelope","category":"Activities","version":"11.0","skinTone":false,"keywords":["red","envelope","gift","good","hóngbāo","lai","luck","money","see"]}, + {"emoji":"🎀","name":"ribbon","category":"Activities","version":"0.6","skinTone":false,"keywords":["ribbon","celebration"]}, + {"emoji":"🎁","name":"wrapped gift","category":"Activities","version":"0.6","skinTone":false,"keywords":["wrapped","gift","birthday","bow","box","celebration","christmas","present","surprise"]}, + {"emoji":"🎗️","name":"reminder ribbon","category":"Activities","version":"0.7","skinTone":false,"keywords":["reminder","ribbon"]}, + {"emoji":"🎟️","name":"admission tickets","category":"Activities","version":"0.7","skinTone":false,"keywords":["admission","tickets"]}, + {"emoji":"🎫","name":"ticket","category":"Activities","version":"0.6","skinTone":false,"keywords":["ticket","admission","stub"]}, + {"emoji":"🎖️","name":"military medal","category":"Activities","version":"0.7","skinTone":false,"keywords":["military","medal"]}, + {"emoji":"🏆","name":"trophy","category":"Activities","version":"0.6","skinTone":false,"keywords":["trophy","champion","champs","prize","slay","sport","victory","win","winning"]}, + {"emoji":"🏅","name":"sports medal","category":"Activities","version":"1.0","skinTone":false,"keywords":["sports","medal","award","gold","winner"]}, + {"emoji":"🥇","name":"1st place medal","category":"Activities","version":"3.0","skinTone":false,"keywords":["1st","place","medal","first","gold"]}, + {"emoji":"🥈","name":"2nd place medal","category":"Activities","version":"3.0","skinTone":false,"keywords":["2nd","place","medal","second","silver"]}, + {"emoji":"🥉","name":"3rd place medal","category":"Activities","version":"3.0","skinTone":false,"keywords":["3rd","place","medal","bronze","third"]}, + {"emoji":"⚽","name":"soccer ball","category":"Activities","version":"0.6","skinTone":false,"keywords":["soccer","ball","football","futbol","sport"]}, + {"emoji":"⚾","name":"baseball","category":"Activities","version":"0.6","skinTone":false,"keywords":["baseball","ball","sport"]}, + {"emoji":"🥎","name":"softball","category":"Activities","version":"11.0","skinTone":false,"keywords":["softball","ball","glove","sports","underarm"]}, + {"emoji":"🏀","name":"basketball","category":"Activities","version":"0.6","skinTone":false,"keywords":["basketball","ball","hoop","sport"]}, + {"emoji":"🏐","name":"volleyball","category":"Activities","version":"1.0","skinTone":false,"keywords":["volleyball","ball","game"]}, + {"emoji":"🏈","name":"american football","category":"Activities","version":"0.6","skinTone":false,"keywords":["american","football","ball","bowl","sport","super"]}, + {"emoji":"🏉","name":"rugby football","category":"Activities","version":"1.0","skinTone":false,"keywords":["rugby","football","ball","sport"]}, + {"emoji":"🎾","name":"tennis","category":"Activities","version":"0.6","skinTone":false,"keywords":["tennis","ball","racquet","sport"]}, + {"emoji":"🥏","name":"flying disc","category":"Activities","version":"11.0","skinTone":false,"keywords":["flying","disc","ultimate"]}, + {"emoji":"🎳","name":"bowling","category":"Activities","version":"0.6","skinTone":false,"keywords":["bowling","ball","game","sport","strike"]}, + {"emoji":"🏏","name":"cricket game","category":"Activities","version":"1.0","skinTone":false,"keywords":["cricket","game","ball","bat"]}, + {"emoji":"🏑","name":"field hockey","category":"Activities","version":"1.0","skinTone":false,"keywords":["field","hockey","ball","game","stick"]}, + {"emoji":"🏒","name":"ice hockey","category":"Activities","version":"1.0","skinTone":false,"keywords":["ice","hockey","game","puck","stick"]}, + {"emoji":"🥍","name":"lacrosse","category":"Activities","version":"11.0","skinTone":false,"keywords":["lacrosse","ball","goal","sports","stick"]}, + {"emoji":"🏓","name":"ping pong","category":"Activities","version":"1.0","skinTone":false,"keywords":["ping","pong","ball","bat","game","paddle","pingpong","table","tennis"]}, + {"emoji":"🏸","name":"badminton","category":"Activities","version":"1.0","skinTone":false,"keywords":["badminton","birdie","game","racquet","shuttlecock"]}, + {"emoji":"🥊","name":"boxing glove","category":"Activities","version":"3.0","skinTone":false,"keywords":["boxing","glove"]}, + {"emoji":"🥋","name":"martial arts uniform","category":"Activities","version":"3.0","skinTone":false,"keywords":["martial","arts","uniform","judo","karate","taekwondo"]}, + {"emoji":"🥅","name":"goal net","category":"Activities","version":"3.0","skinTone":false,"keywords":["goal","net"]}, + {"emoji":"⛳","name":"flag in hole","category":"Activities","version":"0.6","skinTone":false,"keywords":["flag","in","hole","golf","sport"]}, + {"emoji":"⛸️","name":"ice skate","category":"Activities","version":"0.7","skinTone":false,"keywords":["ice","skate"]}, + {"emoji":"🎣","name":"fishing pole","category":"Activities","version":"0.6","skinTone":false,"keywords":["fishing","pole","entertainment","fish","sport"]}, + {"emoji":"🤿","name":"diving mask","category":"Activities","version":"12.0","skinTone":false,"keywords":["diving","mask","scuba","snorkeling"]}, + {"emoji":"🎽","name":"running shirt","category":"Activities","version":"0.6","skinTone":false,"keywords":["running","shirt","athletics","sash"]}, + {"emoji":"🎿","name":"skis","category":"Activities","version":"0.6","skinTone":false,"keywords":["skis","ski","snow","sport"]}, + {"emoji":"🛷","name":"sled","category":"Activities","version":"5.0","skinTone":false,"keywords":["sled","luge","sledge","sleigh","snow","toboggan"]}, + {"emoji":"🥌","name":"curling stone","category":"Activities","version":"5.0","skinTone":false,"keywords":["curling","stone","game","rock"]}, + {"emoji":"🎯","name":"bullseye","category":"Activities","version":"0.6","skinTone":false,"keywords":["bullseye","bull","dart","direct","entertainment","game","hit","target"]}, + {"emoji":"🪀","name":"yo-yo","category":"Activities","version":"12.0","skinTone":false,"keywords":["yo-yo","fluctuate","toy"]}, + {"emoji":"🪁","name":"kite","category":"Activities","version":"12.0","skinTone":false,"keywords":["kite","fly","soar"]}, + {"emoji":"🔫","name":"water pistol","category":"Activities","version":"0.6","skinTone":false,"keywords":["water","pistol","gun","handgun","revolver","tool","weapon"]}, + {"emoji":"🎱","name":"pool 8 ball","category":"Activities","version":"0.6","skinTone":false,"keywords":["pool","8","ball","8ball","billiard","eight","game"]}, + {"emoji":"🔮","name":"crystal ball","category":"Activities","version":"0.6","skinTone":false,"keywords":["crystal","ball","fairy","fairytale","fantasy","fortune","future","magic","tale","tool"]}, + {"emoji":"🪄","name":"magic wand","category":"Activities","version":"13.0","skinTone":false,"keywords":["magic","wand","magician","witch","wizard"]}, + {"emoji":"🎮","name":"video game","category":"Activities","version":"0.6","skinTone":false,"keywords":["video","game","controller","entertainment"]}, + {"emoji":"🕹️","name":"joystick","category":"Activities","version":"0.7","skinTone":false,"keywords":["joystick"]}, + {"emoji":"🎰","name":"slot machine","category":"Activities","version":"0.6","skinTone":false,"keywords":["slot","machine","casino","gamble","gambling","game","slots"]}, + {"emoji":"🎲","name":"game die","category":"Activities","version":"0.6","skinTone":false,"keywords":["game","die","dice","entertainment"]}, + {"emoji":"🧩","name":"puzzle piece","category":"Activities","version":"11.0","skinTone":false,"keywords":["puzzle","piece","clue","interlocking","jigsaw"]}, + {"emoji":"🧸","name":"teddy bear","category":"Activities","version":"11.0","skinTone":false,"keywords":["teddy","bear","plaything","plush","stuffed","toy"]}, + {"emoji":"🪅","name":"piñata","category":"Activities","version":"13.0","skinTone":false,"keywords":["piñata","candy","celebrate","celebration","cinco","de","festive","mayo","party","pinada","pinata"]}, + {"emoji":"🪩","name":"mirror ball","category":"Activities","version":"14.0","skinTone":false,"keywords":["mirror","ball","dance","disco","glitter","party"]}, + {"emoji":"🪆","name":"nesting dolls","category":"Activities","version":"13.0","skinTone":false,"keywords":["nesting","dolls","babooshka","baboushka","babushka","doll","matryoshka","russia"]}, + {"emoji":"♠️","name":"spade suit","category":"Activities","version":"0.6","skinTone":false,"keywords":["spade","suit"]}, + {"emoji":"♥️","name":"heart suit","category":"Activities","version":"0.6","skinTone":false,"keywords":["heart","suit"]}, + {"emoji":"♦️","name":"diamond suit","category":"Activities","version":"0.6","skinTone":false,"keywords":["diamond","suit"]}, + {"emoji":"♣️","name":"club suit","category":"Activities","version":"0.6","skinTone":false,"keywords":["club","suit"]}, + {"emoji":"♟️","name":"chess pawn","category":"Activities","version":"11.0","skinTone":false,"keywords":["chess","pawn"]}, + {"emoji":"🃏","name":"joker","category":"Activities","version":"0.6","skinTone":false,"keywords":["joker","card","game","wildcard"]}, + {"emoji":"🀄","name":"mahjong red dragon","category":"Activities","version":"0.6","skinTone":false,"keywords":["mahjong","red","dragon","game"]}, + {"emoji":"🎴","name":"flower playing cards","category":"Activities","version":"0.6","skinTone":false,"keywords":["flower","playing","cards","card","game","Japanese"]}, + {"emoji":"🎭","name":"performing arts","category":"Activities","version":"0.6","skinTone":false,"keywords":["performing","arts","actor","actress","art","entertainment","mask","theater","theatre","thespian"]}, + {"emoji":"🖼️","name":"framed picture","category":"Activities","version":"0.7","skinTone":false,"keywords":["framed","picture"]}, + {"emoji":"🎨","name":"artist palette","category":"Activities","version":"0.6","skinTone":false,"keywords":["artist","palette","art","artsy","arty","colorful","creative","entertainment","museum","painter","painting"]}, + {"emoji":"🧵","name":"thread","category":"Activities","version":"11.0","skinTone":false,"keywords":["thread","needle","sewing","spool","string"]}, + {"emoji":"🪡","name":"sewing needle","category":"Activities","version":"13.0","skinTone":false,"keywords":["sewing","needle","embroidery","sew","stitches","sutures","tailoring","thread"]}, + {"emoji":"🧶","name":"yarn","category":"Activities","version":"11.0","skinTone":false,"keywords":["yarn","ball","crochet","knit"]}, + {"emoji":"🪢","name":"knot","category":"Activities","version":"13.0","skinTone":false,"keywords":["knot","cord","rope","tangled","tie","twine","twist"]}, + {"emoji":"👓","name":"glasses","category":"Objects","version":"0.6","skinTone":false,"keywords":["glasses","clothing","eye","eyeglasses","eyewear"]}, + {"emoji":"🕶️","name":"sunglasses","category":"Objects","version":"0.7","skinTone":false,"keywords":["sunglasses"]}, + {"emoji":"🥽","name":"goggles","category":"Objects","version":"11.0","skinTone":false,"keywords":["goggles","dive","eye","protection","scuba","swimming","welding"]}, + {"emoji":"🥼","name":"lab coat","category":"Objects","version":"11.0","skinTone":false,"keywords":["lab","coat","clothes","doctor","dr","experiment","jacket","scientist","white"]}, + {"emoji":"🦺","name":"safety vest","category":"Objects","version":"12.0","skinTone":false,"keywords":["safety","vest","emergency"]}, + {"emoji":"👔","name":"necktie","category":"Objects","version":"0.6","skinTone":false,"keywords":["necktie","clothing","employed","serious","shirt","tie"]}, + {"emoji":"👕","name":"t-shirt","category":"Objects","version":"0.6","skinTone":false,"keywords":["t-shirt","blue","casual","clothes","clothing","collar","dressed","shirt","shopping","tshirt","weekend"]}, + {"emoji":"👖","name":"jeans","category":"Objects","version":"0.6","skinTone":false,"keywords":["jeans","blue","casual","clothes","clothing","denim","dressed","pants","shopping","trousers","weekend"]}, + {"emoji":"🧣","name":"scarf","category":"Objects","version":"5.0","skinTone":false,"keywords":["scarf","bundle","cold","neck","up"]}, + {"emoji":"🧤","name":"gloves","category":"Objects","version":"5.0","skinTone":false,"keywords":["gloves","hand"]}, + {"emoji":"🧥","name":"coat","category":"Objects","version":"5.0","skinTone":false,"keywords":["coat","brr","bundle","cold","jacket","up"]}, + {"emoji":"🧦","name":"socks","category":"Objects","version":"5.0","skinTone":false,"keywords":["socks","stocking"]}, + {"emoji":"👗","name":"dress","category":"Objects","version":"0.6","skinTone":false,"keywords":["dress","clothes","clothing","dressed","fancy","shopping"]}, + {"emoji":"👘","name":"kimono","category":"Objects","version":"0.6","skinTone":false,"keywords":["kimono","clothing","comfortable"]}, + {"emoji":"🥻","name":"sari","category":"Objects","version":"12.0","skinTone":false,"keywords":["sari","clothing","dress"]}, + {"emoji":"🩱","name":"one-piece swimsuit","category":"Objects","version":"12.0","skinTone":false,"keywords":["one-piece","swimsuit","bathing","suit"]}, + {"emoji":"🩲","name":"briefs","category":"Objects","version":"12.0","skinTone":false,"keywords":["briefs","bathing","one-piece","suit","swimsuit","underwear"]}, + {"emoji":"🩳","name":"shorts","category":"Objects","version":"12.0","skinTone":false,"keywords":["shorts","bathing","pants","suit","swimsuit","underwear"]}, + {"emoji":"👙","name":"bikini","category":"Objects","version":"0.6","skinTone":false,"keywords":["bikini","bathing","beach","clothing","pool","suit","swim"]}, + {"emoji":"👚","name":"woman’s clothes","category":"Objects","version":"0.6","skinTone":false,"keywords":["woman’s","clothes","blouse","clothing","collar","dress","dressed","lady","shirt","shopping","woman"]}, + {"emoji":"🪭","name":"folding hand fan","category":"Objects","version":"15.0","skinTone":false,"keywords":["folding","hand","fan","clack","clap","cool","cooling","dance","flirt","flutter","hot","shy"]}, + {"emoji":"👛","name":"purse","category":"Objects","version":"0.6","skinTone":false,"keywords":["purse","clothes","clothing","coin","dress","fancy","handbag","shopping"]}, + {"emoji":"👜","name":"handbag","category":"Objects","version":"0.6","skinTone":false,"keywords":["handbag","bag","clothes","clothing","dress","lady","purse","shopping"]}, + {"emoji":"👝","name":"clutch bag","category":"Objects","version":"0.6","skinTone":false,"keywords":["clutch","bag","clothes","clothing","dress","handbag","pouch","purse"]}, + {"emoji":"🛍️","name":"shopping bags","category":"Objects","version":"0.7","skinTone":false,"keywords":["shopping","bags"]}, + {"emoji":"🎒","name":"backpack","category":"Objects","version":"0.6","skinTone":false,"keywords":["backpack","backpacking","bag","bookbag","education","rucksack","satchel","school"]}, + {"emoji":"🩴","name":"thong sandal","category":"Objects","version":"13.0","skinTone":false,"keywords":["thong","sandal","beach","flip","flop","sandals","shoe","thongs","zōri"]}, + {"emoji":"👞","name":"man’s shoe","category":"Objects","version":"0.6","skinTone":false,"keywords":["man’s","shoe","brown","clothes","clothing","feet","foot","kick","man","shoes","shopping"]}, + {"emoji":"👟","name":"running shoe","category":"Objects","version":"0.6","skinTone":false,"keywords":["running","shoe","athletic","clothes","clothing","fast","kick","shoes","shopping","sneaker","tennis"]}, + {"emoji":"🥾","name":"hiking boot","category":"Objects","version":"11.0","skinTone":false,"keywords":["hiking","boot","backpacking","brown","camping","outdoors","shoe"]}, + {"emoji":"🥿","name":"flat shoe","category":"Objects","version":"11.0","skinTone":false,"keywords":["flat","shoe","ballet","comfy","flats","slip-on","slipper"]}, + {"emoji":"👠","name":"high-heeled shoe","category":"Objects","version":"0.6","skinTone":false,"keywords":["high-heeled","shoe","clothes","clothing","dress","fashion","heel","heels","shoes","shopping","stiletto","woman"]}, + {"emoji":"👡","name":"woman’s sandal","category":"Objects","version":"0.6","skinTone":false,"keywords":["woman’s","sandal","clothing","shoe","woman"]}, + {"emoji":"🩰","name":"ballet shoes","category":"Objects","version":"12.0","skinTone":false,"keywords":["ballet","shoes","dance"]}, + {"emoji":"👢","name":"woman’s boot","category":"Objects","version":"0.6","skinTone":false,"keywords":["woman’s","boot","clothes","clothing","dress","shoe","shoes","shopping","woman"]}, + {"emoji":"🪮","name":"hair pick","category":"Objects","version":"15.0","skinTone":false,"keywords":["hair","pick","Afro","comb","groom"]}, + {"emoji":"👑","name":"crown","category":"Objects","version":"0.6","skinTone":false,"keywords":["crown","clothing","family","king","medieval","queen","royal","royalty","win"]}, + {"emoji":"👒","name":"woman’s hat","category":"Objects","version":"0.6","skinTone":false,"keywords":["woman’s","hat","clothes","clothing","garden","hats","party","woman"]}, + {"emoji":"🎩","name":"top hat","category":"Objects","version":"0.6","skinTone":false,"keywords":["top","hat","clothes","clothing","fancy","formal","magic","tophat"]}, + {"emoji":"🎓","name":"graduation cap","category":"Objects","version":"0.6","skinTone":false,"keywords":["graduation","cap","celebration","clothing","education","hat","scholar"]}, + {"emoji":"🧢","name":"billed cap","category":"Objects","version":"5.0","skinTone":false,"keywords":["billed","cap","baseball","bent","dad","hat"]}, + {"emoji":"🪖","name":"military helmet","category":"Objects","version":"13.0","skinTone":false,"keywords":["military","helmet","army","soldier","war","warrior"]}, + {"emoji":"⛑️","name":"rescue worker’s helmet","category":"Objects","version":"0.7","skinTone":false,"keywords":["rescue","worker’s","helmet"]}, + {"emoji":"📿","name":"prayer beads","category":"Objects","version":"1.0","skinTone":false,"keywords":["prayer","beads","clothing","necklace","religion"]}, + {"emoji":"💄","name":"lipstick","category":"Objects","version":"0.6","skinTone":false,"keywords":["lipstick","cosmetics","date","makeup"]}, + {"emoji":"💍","name":"ring","category":"Objects","version":"0.6","skinTone":false,"keywords":["ring","diamond","engaged","engagement","married","romance","shiny","sparkling","wedding"]}, + {"emoji":"💎","name":"gem stone","category":"Objects","version":"0.6","skinTone":false,"keywords":["gem","stone","diamond","engagement","jewel","money","romance","wedding"]}, + {"emoji":"🔇","name":"muted speaker","category":"Objects","version":"1.0","skinTone":false,"keywords":["muted","speaker","mute","quiet","silent","sound"]}, + {"emoji":"🔈","name":"speaker low volume","category":"Objects","version":"0.7","skinTone":false,"keywords":["speaker","low","volume","soft","sound"]}, + {"emoji":"🔉","name":"speaker medium volume","category":"Objects","version":"1.0","skinTone":false,"keywords":["speaker","medium","volume","sound"]}, + {"emoji":"🔊","name":"speaker high volume","category":"Objects","version":"0.6","skinTone":false,"keywords":["speaker","high","volume","loud","music","sound"]}, + {"emoji":"📢","name":"loudspeaker","category":"Objects","version":"0.6","skinTone":false,"keywords":["loudspeaker","address","communication","loud","public","sound"]}, + {"emoji":"📣","name":"megaphone","category":"Objects","version":"0.6","skinTone":false,"keywords":["megaphone","cheering","sound"]}, + {"emoji":"📯","name":"postal horn","category":"Objects","version":"1.0","skinTone":false,"keywords":["postal","horn","post"]}, + {"emoji":"🔔","name":"bell","category":"Objects","version":"0.6","skinTone":false,"keywords":["bell","break","church","sound"]}, + {"emoji":"🔕","name":"bell with slash","category":"Objects","version":"1.0","skinTone":false,"keywords":["bell","with","slash","forbidden","mute","no","not","prohibited","quiet","silent","sound"]}, + {"emoji":"🎼","name":"musical score","category":"Objects","version":"0.6","skinTone":false,"keywords":["musical","score","music","note"]}, + {"emoji":"🎵","name":"musical note","category":"Objects","version":"0.6","skinTone":false,"keywords":["musical","note","music","sound"]}, + {"emoji":"🎶","name":"musical notes","category":"Objects","version":"0.6","skinTone":false,"keywords":["musical","notes","music","note","sound"]}, + {"emoji":"🎙️","name":"studio microphone","category":"Objects","version":"0.7","skinTone":false,"keywords":["studio","microphone"]}, + {"emoji":"🎚️","name":"level slider","category":"Objects","version":"0.7","skinTone":false,"keywords":["level","slider"]}, + {"emoji":"🎛️","name":"control knobs","category":"Objects","version":"0.7","skinTone":false,"keywords":["control","knobs"]}, + {"emoji":"🎤","name":"microphone","category":"Objects","version":"0.6","skinTone":false,"keywords":["microphone","karaoke","mic","music","sing","sound"]}, + {"emoji":"🎧","name":"headphone","category":"Objects","version":"0.6","skinTone":false,"keywords":["headphone","earbud","sound"]}, + {"emoji":"📻","name":"radio","category":"Objects","version":"0.6","skinTone":false,"keywords":["radio","entertainment","tbt","video"]}, + {"emoji":"🎷","name":"saxophone","category":"Objects","version":"0.6","skinTone":false,"keywords":["saxophone","instrument","music","sax"]}, + {"emoji":"🎺","name":"trumpet","category":"Objects","version":"0.6","skinTone":false,"keywords":["trumpet","instrument","music"]}, + {"emoji":"🪊","name":"trombone","category":"Objects","version":"17.0","skinTone":false,"keywords":["trombone","brass","instrument","jazz","music","sad","slide"]}, + {"emoji":"🪗","name":"accordion","category":"Objects","version":"13.0","skinTone":false,"keywords":["accordion","box","concertina","instrument","music","squeeze","squeezebox"]}, + {"emoji":"🎸","name":"guitar","category":"Objects","version":"0.6","skinTone":false,"keywords":["guitar","instrument","music","strat"]}, + {"emoji":"🎹","name":"musical keyboard","category":"Objects","version":"0.6","skinTone":false,"keywords":["musical","keyboard","instrument","music","piano"]}, + {"emoji":"🎻","name":"violin","category":"Objects","version":"0.6","skinTone":false,"keywords":["violin","instrument","music"]}, + {"emoji":"🪕","name":"banjo","category":"Objects","version":"12.0","skinTone":false,"keywords":["banjo","music","stringed"]}, + {"emoji":"🥁","name":"drum","category":"Objects","version":"3.0","skinTone":false,"keywords":["drum","drumsticks","music"]}, + {"emoji":"🪘","name":"long drum","category":"Objects","version":"13.0","skinTone":false,"keywords":["long","drum","beat","conga","instrument","rhythm"]}, + {"emoji":"🪇","name":"maracas","category":"Objects","version":"15.0","skinTone":false,"keywords":["maracas","cha","dance","instrument","music","party","percussion","rattle","shake","shaker"]}, + {"emoji":"🪈","name":"flute","category":"Objects","version":"15.0","skinTone":false,"keywords":["flute","band","fife","flautist","instrument","marching","music","orchestra","piccolo","pipe","recorder","woodwind"]}, + {"emoji":"🪉","name":"harp","category":"Objects","version":"16.0","skinTone":false,"keywords":["harp","cupid","instrument","love","music","orchestra"]}, + {"emoji":"📱","name":"mobile phone","category":"Objects","version":"0.6","skinTone":false,"keywords":["mobile","phone","cell","communication","telephone"]}, + {"emoji":"📲","name":"mobile phone with arrow","category":"Objects","version":"0.6","skinTone":false,"keywords":["mobile","phone","with","arrow","build","call","cell","communication","receive","telephone"]}, + {"emoji":"☎️","name":"telephone","category":"Objects","version":"0.6","skinTone":false,"keywords":["telephone"]}, + {"emoji":"📞","name":"telephone receiver","category":"Objects","version":"0.6","skinTone":false,"keywords":["telephone","receiver","communication","phone","voip"]}, + {"emoji":"📟","name":"pager","category":"Objects","version":"0.6","skinTone":false,"keywords":["pager","communication"]}, + {"emoji":"📠","name":"fax machine","category":"Objects","version":"0.6","skinTone":false,"keywords":["fax","machine","communication"]}, + {"emoji":"🔋","name":"battery","category":"Objects","version":"0.6","skinTone":false,"keywords":["battery"]}, + {"emoji":"🪫","name":"low battery","category":"Objects","version":"14.0","skinTone":false,"keywords":["low","battery","drained","electronic","energy","power"]}, + {"emoji":"🔌","name":"electric plug","category":"Objects","version":"0.6","skinTone":false,"keywords":["electric","plug","electricity"]}, + {"emoji":"💻","name":"laptop","category":"Objects","version":"0.6","skinTone":false,"keywords":["laptop","computer","office","pc","personal"]}, + {"emoji":"🖥️","name":"desktop computer","category":"Objects","version":"0.7","skinTone":false,"keywords":["desktop","computer"]}, + {"emoji":"🖨️","name":"printer","category":"Objects","version":"0.7","skinTone":false,"keywords":["printer"]}, + {"emoji":"⌨️","name":"keyboard","category":"Objects","version":"1.0","skinTone":false,"keywords":["keyboard"]}, + {"emoji":"🖱️","name":"computer mouse","category":"Objects","version":"0.7","skinTone":false,"keywords":["computer","mouse"]}, + {"emoji":"🖲️","name":"trackball","category":"Objects","version":"0.7","skinTone":false,"keywords":["trackball"]}, + {"emoji":"💽","name":"computer disk","category":"Objects","version":"0.6","skinTone":false,"keywords":["computer","disk","minidisk","optical"]}, + {"emoji":"💾","name":"floppy disk","category":"Objects","version":"0.6","skinTone":false,"keywords":["floppy","disk","computer"]}, + {"emoji":"💿","name":"optical disk","category":"Objects","version":"0.6","skinTone":false,"keywords":["optical","disk","blu-ray","CD","computer","dvd"]}, + {"emoji":"📀","name":"dvd","category":"Objects","version":"0.6","skinTone":false,"keywords":["dvd","Blu-ray","cd","computer","disk","DVD","optical"]}, + {"emoji":"🧮","name":"abacus","category":"Objects","version":"11.0","skinTone":false,"keywords":["abacus","calculation","calculator"]}, + {"emoji":"🎥","name":"movie camera","category":"Objects","version":"0.6","skinTone":false,"keywords":["movie","camera","bollywood","cinema","film","hollywood","record"]}, + {"emoji":"🎞️","name":"film frames","category":"Objects","version":"0.7","skinTone":false,"keywords":["film","frames"]}, + {"emoji":"📽️","name":"film projector","category":"Objects","version":"0.7","skinTone":false,"keywords":["film","projector"]}, + {"emoji":"🎬","name":"clapper board","category":"Objects","version":"0.6","skinTone":false,"keywords":["clapper","board","action","movie"]}, + {"emoji":"📺","name":"television","category":"Objects","version":"0.6","skinTone":false,"keywords":["television","tv","video"]}, + {"emoji":"📷","name":"camera","category":"Objects","version":"0.6","skinTone":false,"keywords":["camera","photo","selfie","snap","tbt","trip","video"]}, + {"emoji":"📸","name":"camera with flash","category":"Objects","version":"1.0","skinTone":false,"keywords":["camera","with","flash","video"]}, + {"emoji":"📹","name":"video camera","category":"Objects","version":"0.6","skinTone":false,"keywords":["video","camera","camcorder","tbt"]}, + {"emoji":"📼","name":"videocassette","category":"Objects","version":"0.6","skinTone":false,"keywords":["videocassette","old","school","tape","vcr","vhs","video"]}, + {"emoji":"🔍","name":"magnifying glass tilted left","category":"Objects","version":"0.6","skinTone":false,"keywords":["magnifying","glass","tilted","left","lab","left-pointing","science","search","tool"]}, + {"emoji":"🔎","name":"magnifying glass tilted right","category":"Objects","version":"0.6","skinTone":false,"keywords":["magnifying","glass","tilted","right","contact","lab","right-pointing","science","search","tool"]}, + {"emoji":"🕯️","name":"candle","category":"Objects","version":"0.7","skinTone":false,"keywords":["candle"]}, + {"emoji":"💡","name":"light bulb","category":"Objects","version":"0.6","skinTone":false,"keywords":["light","bulb","comic","electric","idea"]}, + {"emoji":"🔦","name":"flashlight","category":"Objects","version":"0.6","skinTone":false,"keywords":["flashlight","electric","light","tool","torch"]}, + {"emoji":"🏮","name":"red paper lantern","category":"Objects","version":"0.6","skinTone":false,"keywords":["red","paper","lantern","bar","light","restaurant"]}, + {"emoji":"🪔","name":"diya lamp","category":"Objects","version":"12.0","skinTone":false,"keywords":["diya","lamp","light","oil"]}, + {"emoji":"📔","name":"notebook with decorative cover","category":"Objects","version":"0.6","skinTone":false,"keywords":["notebook","with","decorative","cover","book","decorated","education","school","writing"]}, + {"emoji":"📕","name":"closed book","category":"Objects","version":"0.6","skinTone":false,"keywords":["closed","book","education"]}, + {"emoji":"📖","name":"open book","category":"Objects","version":"0.6","skinTone":false,"keywords":["open","book","education","fantasy","knowledge","library","novels","reading"]}, + {"emoji":"📗","name":"green book","category":"Objects","version":"0.6","skinTone":false,"keywords":["green","book","education","fantasy","library","reading"]}, + {"emoji":"📘","name":"blue book","category":"Objects","version":"0.6","skinTone":false,"keywords":["blue","book","education","fantasy","library","reading"]}, + {"emoji":"📙","name":"orange book","category":"Objects","version":"0.6","skinTone":false,"keywords":["orange","book","education","fantasy","library","reading"]}, + {"emoji":"📚","name":"books","category":"Objects","version":"0.6","skinTone":false,"keywords":["books","book","education","fantasy","knowledge","library","novels","reading","school","study"]}, + {"emoji":"📓","name":"notebook","category":"Objects","version":"0.6","skinTone":false,"keywords":["notebook"]}, + {"emoji":"📒","name":"ledger","category":"Objects","version":"0.6","skinTone":false,"keywords":["ledger","notebook"]}, + {"emoji":"📃","name":"page with curl","category":"Objects","version":"0.6","skinTone":false,"keywords":["page","with","curl","document","paper"]}, + {"emoji":"📜","name":"scroll","category":"Objects","version":"0.6","skinTone":false,"keywords":["scroll","paper"]}, + {"emoji":"📄","name":"page facing up","category":"Objects","version":"0.6","skinTone":false,"keywords":["page","facing","up","document","paper"]}, + {"emoji":"📰","name":"newspaper","category":"Objects","version":"0.6","skinTone":false,"keywords":["newspaper","communication","news","paper"]}, + {"emoji":"🗞️","name":"rolled-up newspaper","category":"Objects","version":"0.7","skinTone":false,"keywords":["rolled-up","newspaper"]}, + {"emoji":"📑","name":"bookmark tabs","category":"Objects","version":"0.6","skinTone":false,"keywords":["bookmark","tabs","mark","marker"]}, + {"emoji":"🔖","name":"bookmark","category":"Objects","version":"0.6","skinTone":false,"keywords":["bookmark","mark"]}, + {"emoji":"🏷️","name":"label","category":"Objects","version":"0.7","skinTone":false,"keywords":["label"]}, + {"emoji":"🪙","name":"coin","category":"Objects","version":"13.0","skinTone":false,"keywords":["coin","dollar","euro","gold","metal","money","rich","silver","treasure"]}, + {"emoji":"💰","name":"money bag","category":"Objects","version":"0.6","skinTone":false,"keywords":["money","bag","bank","bet","billion","cash","cost","dollar","gold","million","moneybag","paid","paying","pot","rich","win"]}, + {"emoji":"🪎","name":"treasure chest","category":"Objects","version":"17.0","skinTone":false,"keywords":["treasure","chest","gem","gold","jewels","loot","money","prize","silver","valuables","wealth"]}, + {"emoji":"💴","name":"yen banknote","category":"Objects","version":"0.6","skinTone":false,"keywords":["yen","banknote","bank","bill","currency","money","note"]}, + {"emoji":"💵","name":"dollar banknote","category":"Objects","version":"0.6","skinTone":false,"keywords":["dollar","banknote","bank","bill","currency","money","note"]}, + {"emoji":"💶","name":"euro banknote","category":"Objects","version":"1.0","skinTone":false,"keywords":["euro","banknote","100","bank","bill","currency","money","note","rich"]}, + {"emoji":"💷","name":"pound banknote","category":"Objects","version":"1.0","skinTone":false,"keywords":["pound","banknote","bank","bill","billion","cash","currency","money","note","pounds"]}, + {"emoji":"💸","name":"money with wings","category":"Objects","version":"0.6","skinTone":false,"keywords":["money","with","wings","bank","banknote","bill","billion","cash","dollar","fly","million","note","pay"]}, + {"emoji":"💳","name":"credit card","category":"Objects","version":"0.6","skinTone":false,"keywords":["credit","card","bank","cash","charge","money","pay"]}, + {"emoji":"🧾","name":"receipt","category":"Objects","version":"11.0","skinTone":false,"keywords":["receipt","accounting","bookkeeping","evidence","invoice","proof"]}, + {"emoji":"💹","name":"chart increasing with yen","category":"Objects","version":"0.6","skinTone":false,"keywords":["chart","increasing","with","yen","bank","currency","graph","growth","market","money","rise","trend","upward"]}, + {"emoji":"✉️","name":"envelope","category":"Objects","version":"0.6","skinTone":false,"keywords":["envelope"]}, + {"emoji":"📧","name":"e-mail","category":"Objects","version":"0.6","skinTone":false,"keywords":["e-mail","email","letter","mail"]}, + {"emoji":"📨","name":"incoming envelope","category":"Objects","version":"0.6","skinTone":false,"keywords":["incoming","envelope","delivering","e-mail","email","letter","mail","receive","sent"]}, + {"emoji":"📩","name":"envelope with arrow","category":"Objects","version":"0.6","skinTone":false,"keywords":["envelope","with","arrow","communication","down","e-mail","email","letter","mail","outgoing","send","sent"]}, + {"emoji":"📤","name":"outbox tray","category":"Objects","version":"0.6","skinTone":false,"keywords":["outbox","tray","box","email","letter","mail","sent"]}, + {"emoji":"📥","name":"inbox tray","category":"Objects","version":"0.6","skinTone":false,"keywords":["inbox","tray","box","email","letter","mail","receive","zero"]}, + {"emoji":"📦","name":"package","category":"Objects","version":"0.6","skinTone":false,"keywords":["package","box","communication","delivery","parcel","shipping"]}, + {"emoji":"📫","name":"closed mailbox with raised flag","category":"Objects","version":"0.6","skinTone":false,"keywords":["closed","mailbox","with","raised","flag","communication","mail","postbox"]}, + {"emoji":"📪","name":"closed mailbox with lowered flag","category":"Objects","version":"0.6","skinTone":false,"keywords":["closed","mailbox","with","lowered","flag","mail","postbox"]}, + {"emoji":"📬","name":"open mailbox with raised flag","category":"Objects","version":"0.7","skinTone":false,"keywords":["open","mailbox","with","raised","flag","mail","postbox"]}, + {"emoji":"📭","name":"open mailbox with lowered flag","category":"Objects","version":"0.7","skinTone":false,"keywords":["open","mailbox","with","lowered","flag","mail","postbox"]}, + {"emoji":"📮","name":"postbox","category":"Objects","version":"0.6","skinTone":false,"keywords":["postbox","mail","mailbox"]}, + {"emoji":"🗳️","name":"ballot box with ballot","category":"Objects","version":"0.7","skinTone":false,"keywords":["ballot","box","with"]}, + {"emoji":"✏️","name":"pencil","category":"Objects","version":"0.6","skinTone":false,"keywords":["pencil"]}, + {"emoji":"✒️","name":"black nib","category":"Objects","version":"0.6","skinTone":false,"keywords":["black","nib"]}, + {"emoji":"🖋️","name":"fountain pen","category":"Objects","version":"0.7","skinTone":false,"keywords":["fountain","pen"]}, + {"emoji":"🖊️","name":"pen","category":"Objects","version":"0.7","skinTone":false,"keywords":["pen"]}, + {"emoji":"🖌️","name":"paintbrush","category":"Objects","version":"0.7","skinTone":false,"keywords":["paintbrush"]}, + {"emoji":"🖍️","name":"crayon","category":"Objects","version":"0.7","skinTone":false,"keywords":["crayon"]}, + {"emoji":"📝","name":"memo","category":"Objects","version":"0.6","skinTone":false,"keywords":["memo","communication","media","notes","pencil"]}, + {"emoji":"💼","name":"briefcase","category":"Objects","version":"0.6","skinTone":false,"keywords":["briefcase","office"]}, + {"emoji":"📁","name":"file folder","category":"Objects","version":"0.6","skinTone":false,"keywords":["file","folder"]}, + {"emoji":"📂","name":"open file folder","category":"Objects","version":"0.6","skinTone":false,"keywords":["open","file","folder"]}, + {"emoji":"🗂️","name":"card index dividers","category":"Objects","version":"0.7","skinTone":false,"keywords":["card","index","dividers"]}, + {"emoji":"📅","name":"calendar","category":"Objects","version":"0.6","skinTone":false,"keywords":["calendar","date"]}, + {"emoji":"📆","name":"tear-off calendar","category":"Objects","version":"0.6","skinTone":false,"keywords":["tear-off","calendar"]}, + {"emoji":"🗒️","name":"spiral notepad","category":"Objects","version":"0.7","skinTone":false,"keywords":["spiral","notepad"]}, + {"emoji":"🗓️","name":"spiral calendar","category":"Objects","version":"0.7","skinTone":false,"keywords":["spiral","calendar"]}, + {"emoji":"📇","name":"card index","category":"Objects","version":"0.6","skinTone":false,"keywords":["card","index","old","rolodex","school"]}, + {"emoji":"📈","name":"chart increasing","category":"Objects","version":"0.6","skinTone":false,"keywords":["chart","increasing","data","graph","growth","right","trend","up","upward"]}, + {"emoji":"📉","name":"chart decreasing","category":"Objects","version":"0.6","skinTone":false,"keywords":["chart","decreasing","data","down","downward","graph","negative","trend"]}, + {"emoji":"📊","name":"bar chart","category":"Objects","version":"0.6","skinTone":false,"keywords":["bar","chart","data","graph"]}, + {"emoji":"📋","name":"clipboard","category":"Objects","version":"0.6","skinTone":false,"keywords":["clipboard","do","list","notes"]}, + {"emoji":"📌","name":"pushpin","category":"Objects","version":"0.6","skinTone":false,"keywords":["pushpin","collage","pin"]}, + {"emoji":"📍","name":"round pushpin","category":"Objects","version":"0.6","skinTone":false,"keywords":["round","pushpin","location","map","pin"]}, + {"emoji":"📎","name":"paperclip","category":"Objects","version":"0.6","skinTone":false,"keywords":["paperclip"]}, + {"emoji":"🖇️","name":"linked paperclips","category":"Objects","version":"0.7","skinTone":false,"keywords":["linked","paperclips"]}, + {"emoji":"📏","name":"straight ruler","category":"Objects","version":"0.6","skinTone":false,"keywords":["straight","ruler","angle","edge","math","straightedge"]}, + {"emoji":"📐","name":"triangular ruler","category":"Objects","version":"0.6","skinTone":false,"keywords":["triangular","ruler","angle","math","rule","set","slide","triangle"]}, + {"emoji":"✂️","name":"scissors","category":"Objects","version":"0.6","skinTone":false,"keywords":["scissors"]}, + {"emoji":"🗃️","name":"card file box","category":"Objects","version":"0.7","skinTone":false,"keywords":["card","file","box"]}, + {"emoji":"🗄️","name":"file cabinet","category":"Objects","version":"0.7","skinTone":false,"keywords":["file","cabinet"]}, + {"emoji":"🗑️","name":"wastebasket","category":"Objects","version":"0.7","skinTone":false,"keywords":["wastebasket"]}, + {"emoji":"🔒","name":"locked","category":"Objects","version":"0.6","skinTone":false,"keywords":["locked","closed","lock","private"]}, + {"emoji":"🔓","name":"unlocked","category":"Objects","version":"0.6","skinTone":false,"keywords":["unlocked","cracked","lock","open","unlock"]}, + {"emoji":"🔏","name":"locked with pen","category":"Objects","version":"0.6","skinTone":false,"keywords":["locked","with","pen","ink","lock","nib","privacy"]}, + {"emoji":"🔐","name":"locked with key","category":"Objects","version":"0.6","skinTone":false,"keywords":["locked","with","key","bike","closed","lock","secure"]}, + {"emoji":"🔑","name":"key","category":"Objects","version":"0.6","skinTone":false,"keywords":["key","keys","lock","major","password","unlock"]}, + {"emoji":"🗝️","name":"old key","category":"Objects","version":"0.7","skinTone":false,"keywords":["old","key"]}, + {"emoji":"🔨","name":"hammer","category":"Objects","version":"0.6","skinTone":false,"keywords":["hammer","home","improvement","repairs","tool"]}, + {"emoji":"🪓","name":"axe","category":"Objects","version":"12.0","skinTone":false,"keywords":["axe","ax","chop","hatchet","split","wood"]}, + {"emoji":"⛏️","name":"pick","category":"Objects","version":"0.7","skinTone":false,"keywords":["pick"]}, + {"emoji":"⚒️","name":"hammer and pick","category":"Objects","version":"1.0","skinTone":false,"keywords":["hammer","and","pick"]}, + {"emoji":"🛠️","name":"hammer and wrench","category":"Objects","version":"0.7","skinTone":false,"keywords":["hammer","and","wrench"]}, + {"emoji":"🗡️","name":"dagger","category":"Objects","version":"0.7","skinTone":false,"keywords":["dagger"]}, + {"emoji":"⚔️","name":"crossed swords","category":"Objects","version":"1.0","skinTone":false,"keywords":["crossed","swords"]}, + {"emoji":"💣","name":"bomb","category":"Objects","version":"0.6","skinTone":false,"keywords":["bomb","boom","comic","dangerous","explosion","hot"]}, + {"emoji":"🪃","name":"boomerang","category":"Objects","version":"13.0","skinTone":false,"keywords":["boomerang","rebound","repercussion","weapon"]}, + {"emoji":"🏹","name":"bow and arrow","category":"Objects","version":"1.0","skinTone":false,"keywords":["bow","and","arrow","archer","archery","Sagittarius","tool","weapon","zodiac"]}, + {"emoji":"🛡️","name":"shield","category":"Objects","version":"0.7","skinTone":false,"keywords":["shield"]}, + {"emoji":"🪚","name":"carpentry saw","category":"Objects","version":"13.0","skinTone":false,"keywords":["carpentry","saw","carpenter","cut","lumber","tool","trim"]}, + {"emoji":"🔧","name":"wrench","category":"Objects","version":"0.6","skinTone":false,"keywords":["wrench","home","improvement","spanner","tool"]}, + {"emoji":"🪛","name":"screwdriver","category":"Objects","version":"13.0","skinTone":false,"keywords":["screwdriver","flathead","handy","screw","tool"]}, + {"emoji":"🔩","name":"nut and bolt","category":"Objects","version":"0.6","skinTone":false,"keywords":["nut","and","bolt","home","improvement","tool"]}, + {"emoji":"⚙️","name":"gear","category":"Objects","version":"1.0","skinTone":false,"keywords":["gear"]}, + {"emoji":"🗜️","name":"clamp","category":"Objects","version":"0.7","skinTone":false,"keywords":["clamp"]}, + {"emoji":"⚖️","name":"balance scale","category":"Objects","version":"1.0","skinTone":false,"keywords":["balance","scale"]}, + {"emoji":"🦯","name":"white cane","category":"Objects","version":"12.0","skinTone":false,"keywords":["white","cane","accessibility","blind","probing"]}, + {"emoji":"🔗","name":"link","category":"Objects","version":"0.6","skinTone":false,"keywords":["link","links"]}, + {"emoji":"⛓️‍💥","name":"broken chain","category":"Objects","version":"15.1","skinTone":false,"keywords":["broken","chain"]}, + {"emoji":"⛓️","name":"chains","category":"Objects","version":"0.7","skinTone":false,"keywords":["chains"]}, + {"emoji":"🪝","name":"hook","category":"Objects","version":"13.0","skinTone":false,"keywords":["hook","catch","crook","curve","ensnare","point","selling"]}, + {"emoji":"🧰","name":"toolbox","category":"Objects","version":"11.0","skinTone":false,"keywords":["toolbox","box","chest","mechanic","red","tool"]}, + {"emoji":"🧲","name":"magnet","category":"Objects","version":"11.0","skinTone":false,"keywords":["magnet","attraction","horseshoe","magnetic","negative","positive","shape","u"]}, + {"emoji":"🪜","name":"ladder","category":"Objects","version":"13.0","skinTone":false,"keywords":["ladder","climb","rung","step"]}, + {"emoji":"🪏","name":"shovel","category":"Objects","version":"16.0","skinTone":false,"keywords":["shovel","bury","dig","garden","hole","plant","scoop","snow","spade"]}, + {"emoji":"⚗️","name":"alembic","category":"Objects","version":"1.0","skinTone":false,"keywords":["alembic"]}, + {"emoji":"🧪","name":"test tube","category":"Objects","version":"11.0","skinTone":false,"keywords":["test","tube","chemist","chemistry","experiment","lab","science"]}, + {"emoji":"🧫","name":"petri dish","category":"Objects","version":"11.0","skinTone":false,"keywords":["petri","dish","bacteria","biologist","biology","culture","lab"]}, + {"emoji":"🧬","name":"dna","category":"Objects","version":"11.0","skinTone":false,"keywords":["dna","biologist","evolution","gene","genetics","life"]}, + {"emoji":"🔬","name":"microscope","category":"Objects","version":"1.0","skinTone":false,"keywords":["microscope","experiment","lab","science","tool"]}, + {"emoji":"🔭","name":"telescope","category":"Objects","version":"1.0","skinTone":false,"keywords":["telescope","contact","extraterrestrial","science","tool"]}, + {"emoji":"📡","name":"satellite antenna","category":"Objects","version":"0.6","skinTone":false,"keywords":["satellite","antenna","aliens","contact","dish","science"]}, + {"emoji":"💉","name":"syringe","category":"Objects","version":"0.6","skinTone":false,"keywords":["syringe","doctor","flu","medicine","needle","shot","sick","tool","vaccination"]}, + {"emoji":"🩸","name":"drop of blood","category":"Objects","version":"12.0","skinTone":false,"keywords":["drop","of","blood","bleed","donation","injury","medicine","menstruation"]}, + {"emoji":"💊","name":"pill","category":"Objects","version":"0.6","skinTone":false,"keywords":["pill","doctor","drugs","medicated","medicine","pills","sick","vitamin"]}, + {"emoji":"🩹","name":"adhesive bandage","category":"Objects","version":"12.0","skinTone":false,"keywords":["adhesive","bandage"]}, + {"emoji":"🩼","name":"crutch","category":"Objects","version":"14.0","skinTone":false,"keywords":["crutch","aid","cane","disability","help","hurt","injured","mobility","stick"]}, + {"emoji":"🩺","name":"stethoscope","category":"Objects","version":"12.0","skinTone":false,"keywords":["stethoscope","doctor","heart","medicine"]}, + {"emoji":"🩻","name":"x-ray","category":"Objects","version":"14.0","skinTone":false,"keywords":["x-ray","bones","doctor","medical","skeleton","skull","xray"]}, + {"emoji":"🚪","name":"door","category":"Objects","version":"0.6","skinTone":false,"keywords":["door","back","closet","front"]}, + {"emoji":"🛗","name":"elevator","category":"Objects","version":"13.0","skinTone":false,"keywords":["elevator","accessibility","hoist","lift"]}, + {"emoji":"🪞","name":"mirror","category":"Objects","version":"13.0","skinTone":false,"keywords":["mirror","makeup","reflection","reflector","speculum"]}, + {"emoji":"🪟","name":"window","category":"Objects","version":"13.0","skinTone":false,"keywords":["window","air","frame","fresh","opening","transparent","view"]}, + {"emoji":"🛏️","name":"bed","category":"Objects","version":"0.7","skinTone":false,"keywords":["bed"]}, + {"emoji":"🛋️","name":"couch and lamp","category":"Objects","version":"0.7","skinTone":false,"keywords":["couch","and","lamp"]}, + {"emoji":"🪑","name":"chair","category":"Objects","version":"12.0","skinTone":false,"keywords":["chair","seat","sit"]}, + {"emoji":"🚽","name":"toilet","category":"Objects","version":"0.6","skinTone":false,"keywords":["toilet","bathroom"]}, + {"emoji":"🪠","name":"plunger","category":"Objects","version":"13.0","skinTone":false,"keywords":["plunger","cup","force","plumber","poop","suction","toilet"]}, + {"emoji":"🚿","name":"shower","category":"Objects","version":"1.0","skinTone":false,"keywords":["shower","water"]}, + {"emoji":"🛁","name":"bathtub","category":"Objects","version":"1.0","skinTone":false,"keywords":["bathtub","bath"]}, + {"emoji":"🪤","name":"mouse trap","category":"Objects","version":"13.0","skinTone":false,"keywords":["mouse","trap","bait","cheese","lure","mousetrap","snare"]}, + {"emoji":"🪒","name":"razor","category":"Objects","version":"12.0","skinTone":false,"keywords":["razor","sharp","shave"]}, + {"emoji":"🧴","name":"lotion bottle","category":"Objects","version":"11.0","skinTone":false,"keywords":["lotion","bottle","moisturizer","shampoo","sunscreen"]}, + {"emoji":"🧷","name":"safety pin","category":"Objects","version":"11.0","skinTone":false,"keywords":["safety","pin","diaper","punk","rock"]}, + {"emoji":"🧹","name":"broom","category":"Objects","version":"11.0","skinTone":false,"keywords":["broom","cleaning","sweeping","witch"]}, + {"emoji":"🧺","name":"basket","category":"Objects","version":"11.0","skinTone":false,"keywords":["basket","farming","laundry","picnic"]}, + {"emoji":"🧻","name":"roll of paper","category":"Objects","version":"11.0","skinTone":false,"keywords":["roll","of","paper","toilet","towels"]}, + {"emoji":"🪣","name":"bucket","category":"Objects","version":"13.0","skinTone":false,"keywords":["bucket","cask","pail","vat"]}, + {"emoji":"🧼","name":"soap","category":"Objects","version":"11.0","skinTone":false,"keywords":["soap","bar","bathing","clean","cleaning","lather","soapdish"]}, + {"emoji":"🫧","name":"bubbles","category":"Objects","version":"14.0","skinTone":false,"keywords":["bubbles","bubble","burp","clean","floating","pearl","soap","underwater"]}, + {"emoji":"🪥","name":"toothbrush","category":"Objects","version":"13.0","skinTone":false,"keywords":["toothbrush","bathroom","brush","clean","dental","hygiene","teeth","toiletry"]}, + {"emoji":"🧽","name":"sponge","category":"Objects","version":"11.0","skinTone":false,"keywords":["sponge","absorbing","cleaning","porous","soak"]}, + {"emoji":"🧯","name":"fire extinguisher","category":"Objects","version":"11.0","skinTone":false,"keywords":["fire","extinguisher","extinguish","quench"]}, + {"emoji":"🛒","name":"shopping cart","category":"Objects","version":"3.0","skinTone":false,"keywords":["shopping","cart","trolley"]}, + {"emoji":"🚬","name":"cigarette","category":"Objects","version":"0.6","skinTone":false,"keywords":["cigarette","smoking"]}, + {"emoji":"⚰️","name":"coffin","category":"Objects","version":"1.0","skinTone":false,"keywords":["coffin"]}, + {"emoji":"🪦","name":"headstone","category":"Objects","version":"13.0","skinTone":false,"keywords":["headstone","cemetery","dead","grave","graveyard","memorial","rip","tomb","tombstone"]}, + {"emoji":"⚱️","name":"funeral urn","category":"Objects","version":"1.0","skinTone":false,"keywords":["funeral","urn"]}, + {"emoji":"🧿","name":"nazar amulet","category":"Objects","version":"11.0","skinTone":false,"keywords":["nazar","amulet","bead","blue","charm","evil-eye","talisman"]}, + {"emoji":"🪬","name":"hamsa","category":"Objects","version":"14.0","skinTone":false,"keywords":["hamsa","amulet","Fatima","fortune","guide","hand","Mary","Miriam","palm","protect","protection"]}, + {"emoji":"🗿","name":"moai","category":"Objects","version":"0.6","skinTone":false,"keywords":["moai","face","moyai","statue","stoneface","travel"]}, + {"emoji":"🪧","name":"placard","category":"Objects","version":"13.0","skinTone":false,"keywords":["placard","card","demonstration","notice","picket","plaque","protest","sign"]}, + {"emoji":"🪪","name":"identification card","category":"Objects","version":"14.0","skinTone":false,"keywords":["identification","card","credentials","document","ID","license","security"]}, + {"emoji":"🏧","name":"ATM sign","category":"Symbols","version":"0.6","skinTone":false,"keywords":["ATM","sign","automated","bank","cash","money","teller"]}, + {"emoji":"🚮","name":"litter in bin sign","category":"Symbols","version":"1.0","skinTone":false,"keywords":["litter","in","bin","sign","litterbin"]}, + {"emoji":"🚰","name":"potable water","category":"Symbols","version":"1.0","skinTone":false,"keywords":["potable","water","drinking"]}, + {"emoji":"♿","name":"wheelchair symbol","category":"Symbols","version":"0.6","skinTone":false,"keywords":["wheelchair","symbol","access","handicap"]}, + {"emoji":"🚹","name":"men’s room","category":"Symbols","version":"0.6","skinTone":false,"keywords":["men’s","room","bathroom","lavatory","man","restroom","toilet","WC"]}, + {"emoji":"🚺","name":"women’s room","category":"Symbols","version":"0.6","skinTone":false,"keywords":["women’s","room","bathroom","lavatory","restroom","toilet","WC","woman"]}, + {"emoji":"🚻","name":"restroom","category":"Symbols","version":"0.6","skinTone":false,"keywords":["restroom","bathroom","lavatory","toilet","WC"]}, + {"emoji":"🚼","name":"baby symbol","category":"Symbols","version":"0.6","skinTone":false,"keywords":["baby","symbol","changing"]}, + {"emoji":"🚾","name":"water closet","category":"Symbols","version":"0.6","skinTone":false,"keywords":["water","closet","bathroom","lavatory","restroom","toilet","WC"]}, + {"emoji":"🛂","name":"passport control","category":"Symbols","version":"1.0","skinTone":false,"keywords":["passport","control"]}, + {"emoji":"🛃","name":"customs","category":"Symbols","version":"1.0","skinTone":false,"keywords":["customs","packing"]}, + {"emoji":"🛄","name":"baggage claim","category":"Symbols","version":"1.0","skinTone":false,"keywords":["baggage","claim","arrived","bags","case","checked","journey","packing","plane","ready","travel","trip"]}, + {"emoji":"🛅","name":"left luggage","category":"Symbols","version":"1.0","skinTone":false,"keywords":["left","luggage","baggage","case","locker"]}, + {"emoji":"⚠️","name":"warning","category":"Symbols","version":"0.6","skinTone":false,"keywords":["warning"]}, + {"emoji":"🚸","name":"children crossing","category":"Symbols","version":"1.0","skinTone":false,"keywords":["children","crossing","child","pedestrian","traffic"]}, + {"emoji":"⛔","name":"no entry","category":"Symbols","version":"0.6","skinTone":false,"keywords":["no","entry","do","fail","forbidden","not","pass","prohibited","traffic"]}, + {"emoji":"🚫","name":"prohibited","category":"Symbols","version":"0.6","skinTone":false,"keywords":["prohibited","entry","forbidden","no","not","smoke"]}, + {"emoji":"🚳","name":"no bicycles","category":"Symbols","version":"1.0","skinTone":false,"keywords":["no","bicycles","bicycle","bike","forbidden","not","prohibited"]}, + {"emoji":"🚭","name":"no smoking","category":"Symbols","version":"0.6","skinTone":false,"keywords":["no","smoking","forbidden","not","prohibited","smoke"]}, + {"emoji":"🚯","name":"no littering","category":"Symbols","version":"1.0","skinTone":false,"keywords":["no","littering","forbidden","litter","not","prohibited"]}, + {"emoji":"🚱","name":"non-potable water","category":"Symbols","version":"1.0","skinTone":false,"keywords":["non-potable","water","dry","non-drinking","prohibited"]}, + {"emoji":"🚷","name":"no pedestrians","category":"Symbols","version":"1.0","skinTone":false,"keywords":["no","pedestrians","forbidden","not","pedestrian","prohibited"]}, + {"emoji":"📵","name":"no mobile phones","category":"Symbols","version":"1.0","skinTone":false,"keywords":["no","mobile","phones","cell","forbidden","not","phone","prohibited","telephone"]}, + {"emoji":"🔞","name":"no one under eighteen","category":"Symbols","version":"0.6","skinTone":false,"keywords":["no","one","under","eighteen","18","age","forbidden","not","prohibited","restriction","underage"]}, + {"emoji":"☢️","name":"radioactive","category":"Symbols","version":"1.0","skinTone":false,"keywords":["radioactive"]}, + {"emoji":"☣️","name":"biohazard","category":"Symbols","version":"1.0","skinTone":false,"keywords":["biohazard"]}, + {"emoji":"⬆️","name":"up arrow","category":"Symbols","version":"0.6","skinTone":false,"keywords":["up","arrow"]}, + {"emoji":"↗️","name":"up-right arrow","category":"Symbols","version":"0.6","skinTone":false,"keywords":["up-right","arrow"]}, + {"emoji":"➡️","name":"right arrow","category":"Symbols","version":"0.6","skinTone":false,"keywords":["right","arrow"]}, + {"emoji":"↘️","name":"down-right arrow","category":"Symbols","version":"0.6","skinTone":false,"keywords":["down-right","arrow"]}, + {"emoji":"⬇️","name":"down arrow","category":"Symbols","version":"0.6","skinTone":false,"keywords":["down","arrow"]}, + {"emoji":"↙️","name":"down-left arrow","category":"Symbols","version":"0.6","skinTone":false,"keywords":["down-left","arrow"]}, + {"emoji":"⬅️","name":"left arrow","category":"Symbols","version":"0.6","skinTone":false,"keywords":["left","arrow"]}, + {"emoji":"↖️","name":"up-left arrow","category":"Symbols","version":"0.6","skinTone":false,"keywords":["up-left","arrow"]}, + {"emoji":"↕️","name":"up-down arrow","category":"Symbols","version":"0.6","skinTone":false,"keywords":["up-down","arrow"]}, + {"emoji":"↔️","name":"left-right arrow","category":"Symbols","version":"0.6","skinTone":false,"keywords":["left-right","arrow"]}, + {"emoji":"↩️","name":"right arrow curving left","category":"Symbols","version":"0.6","skinTone":false,"keywords":["right","arrow","curving","left"]}, + {"emoji":"↪️","name":"left arrow curving right","category":"Symbols","version":"0.6","skinTone":false,"keywords":["left","arrow","curving","right"]}, + {"emoji":"⤴️","name":"right arrow curving up","category":"Symbols","version":"0.6","skinTone":false,"keywords":["right","arrow","curving","up"]}, + {"emoji":"⤵️","name":"right arrow curving down","category":"Symbols","version":"0.6","skinTone":false,"keywords":["right","arrow","curving","down"]}, + {"emoji":"🔃","name":"clockwise vertical arrows","category":"Symbols","version":"0.6","skinTone":false,"keywords":["clockwise","vertical","arrows","arrow","refresh","reload"]}, + {"emoji":"🔄","name":"counterclockwise arrows button","category":"Symbols","version":"1.0","skinTone":false,"keywords":["counterclockwise","arrows","button","again","anticlockwise","arrow","deja","refresh","rewindershins","vu"]}, + {"emoji":"🔙","name":"BACK arrow","category":"Symbols","version":"0.6","skinTone":false,"keywords":["BACK","arrow"]}, + {"emoji":"🔚","name":"END arrow","category":"Symbols","version":"0.6","skinTone":false,"keywords":["END","arrow"]}, + {"emoji":"🔛","name":"ON! arrow","category":"Symbols","version":"0.6","skinTone":false,"keywords":["ON!","arrow","mark"]}, + {"emoji":"🔜","name":"SOON arrow","category":"Symbols","version":"0.6","skinTone":false,"keywords":["SOON","arrow","brb","omw"]}, + {"emoji":"🔝","name":"TOP arrow","category":"Symbols","version":"0.6","skinTone":false,"keywords":["TOP","arrow","homie","up"]}, + {"emoji":"🛐","name":"place of worship","category":"Symbols","version":"1.0","skinTone":false,"keywords":["place","of","worship","pray","religion"]}, + {"emoji":"⚛️","name":"atom symbol","category":"Symbols","version":"1.0","skinTone":false,"keywords":["atom","symbol"]}, + {"emoji":"🕉️","name":"om","category":"Symbols","version":"0.7","skinTone":false,"keywords":["om"]}, + {"emoji":"✡️","name":"star of David","category":"Symbols","version":"0.7","skinTone":false,"keywords":["star","of","David"]}, + {"emoji":"☸️","name":"wheel of dharma","category":"Symbols","version":"0.7","skinTone":false,"keywords":["wheel","of","dharma"]}, + {"emoji":"☯️","name":"yin yang","category":"Symbols","version":"0.7","skinTone":false,"keywords":["yin","yang"]}, + {"emoji":"✝️","name":"latin cross","category":"Symbols","version":"0.7","skinTone":false,"keywords":["latin","cross"]}, + {"emoji":"☦️","name":"orthodox cross","category":"Symbols","version":"1.0","skinTone":false,"keywords":["orthodox","cross"]}, + {"emoji":"☪️","name":"star and crescent","category":"Symbols","version":"0.7","skinTone":false,"keywords":["star","and","crescent"]}, + {"emoji":"☮️","name":"peace symbol","category":"Symbols","version":"1.0","skinTone":false,"keywords":["peace","symbol"]}, + {"emoji":"🕎","name":"menorah","category":"Symbols","version":"1.0","skinTone":false,"keywords":["menorah","candelabrum","candlestick","hanukkah","jewish","judaism","religion"]}, + {"emoji":"🔯","name":"dotted six-pointed star","category":"Symbols","version":"0.6","skinTone":false,"keywords":["dotted","six-pointed","star","fortune","jewish","judaism"]}, + {"emoji":"🪯","name":"khanda","category":"Symbols","version":"15.0","skinTone":false,"keywords":["khanda","Deg","Fateh","Khalsa","Khanda","religion","Sikh","Sikhism","Tegh"]}, + {"emoji":"♈","name":"Aries","category":"Symbols","version":"0.6","skinTone":false,"keywords":["Aries","horoscope","ram","zodiac"]}, + {"emoji":"♉","name":"Taurus","category":"Symbols","version":"0.6","skinTone":false,"keywords":["Taurus","bull","horoscope","ox","zodiac"]}, + {"emoji":"♊","name":"Gemini","category":"Symbols","version":"0.6","skinTone":false,"keywords":["Gemini","horoscope","twins","zodiac"]}, + {"emoji":"♋","name":"Cancer","category":"Symbols","version":"0.6","skinTone":false,"keywords":["Cancer","crab","horoscope","zodiac"]}, + {"emoji":"♌","name":"Leo","category":"Symbols","version":"0.6","skinTone":false,"keywords":["Leo","horoscope","lion","zodiac"]}, + {"emoji":"♍","name":"Virgo","category":"Symbols","version":"0.6","skinTone":false,"keywords":["Virgo","horoscope","zodiac"]}, + {"emoji":"♎","name":"Libra","category":"Symbols","version":"0.6","skinTone":false,"keywords":["Libra","balance","horoscope","justice","scales","zodiac"]}, + {"emoji":"♏","name":"Scorpio","category":"Symbols","version":"0.6","skinTone":false,"keywords":["Scorpio","horoscope","scorpion","Scorpius","zodiac"]}, + {"emoji":"♐","name":"Sagittarius","category":"Symbols","version":"0.6","skinTone":false,"keywords":["Sagittarius","archer","horoscope","zodiac"]}, + {"emoji":"♑","name":"Capricorn","category":"Symbols","version":"0.6","skinTone":false,"keywords":["Capricorn","goat","horoscope","zodiac"]}, + {"emoji":"♒","name":"Aquarius","category":"Symbols","version":"0.6","skinTone":false,"keywords":["Aquarius","bearer","horoscope","water","zodiac"]}, + {"emoji":"♓","name":"Pisces","category":"Symbols","version":"0.6","skinTone":false,"keywords":["Pisces","fish","horoscope","zodiac"]}, + {"emoji":"⛎","name":"Ophiuchus","category":"Symbols","version":"0.6","skinTone":false,"keywords":["Ophiuchus","bearer","serpent","snake","zodiac"]}, + {"emoji":"🔀","name":"shuffle tracks button","category":"Symbols","version":"1.0","skinTone":false,"keywords":["shuffle","tracks","button","arrow","crossed"]}, + {"emoji":"🔁","name":"repeat button","category":"Symbols","version":"1.0","skinTone":false,"keywords":["repeat","button","arrow","clockwise"]}, + {"emoji":"🔂","name":"repeat single button","category":"Symbols","version":"1.0","skinTone":false,"keywords":["repeat","single","button","arrow","clockwise","once"]}, + {"emoji":"▶️","name":"play button","category":"Symbols","version":"0.6","skinTone":false,"keywords":["play","button"]}, + {"emoji":"⏩","name":"fast-forward button","category":"Symbols","version":"0.6","skinTone":false,"keywords":["fast-forward","button","arrow","double","fast","forward"]}, + {"emoji":"⏭️","name":"next track button","category":"Symbols","version":"0.7","skinTone":false,"keywords":["next","track","button"]}, + {"emoji":"⏯️","name":"play or pause button","category":"Symbols","version":"1.0","skinTone":false,"keywords":["play","or","pause","button"]}, + {"emoji":"◀️","name":"reverse button","category":"Symbols","version":"0.6","skinTone":false,"keywords":["reverse","button"]}, + {"emoji":"⏪","name":"fast reverse button","category":"Symbols","version":"0.6","skinTone":false,"keywords":["fast","reverse","button","arrow","double","rewind"]}, + {"emoji":"⏮️","name":"last track button","category":"Symbols","version":"0.7","skinTone":false,"keywords":["last","track","button"]}, + {"emoji":"🔼","name":"upwards button","category":"Symbols","version":"0.6","skinTone":false,"keywords":["upwards","button","arrow","red","up"]}, + {"emoji":"⏫","name":"fast up button","category":"Symbols","version":"0.6","skinTone":false,"keywords":["fast","up","button","arrow","double"]}, + {"emoji":"🔽","name":"downwards button","category":"Symbols","version":"0.6","skinTone":false,"keywords":["downwards","button","arrow","down","red"]}, + {"emoji":"⏬","name":"fast down button","category":"Symbols","version":"0.6","skinTone":false,"keywords":["fast","down","button","arrow","double"]}, + {"emoji":"⏸️","name":"pause button","category":"Symbols","version":"0.7","skinTone":false,"keywords":["pause","button"]}, + {"emoji":"⏹️","name":"stop button","category":"Symbols","version":"0.7","skinTone":false,"keywords":["stop","button"]}, + {"emoji":"⏺️","name":"record button","category":"Symbols","version":"0.7","skinTone":false,"keywords":["record","button"]}, + {"emoji":"⏏️","name":"eject button","category":"Symbols","version":"1.0","skinTone":false,"keywords":["eject","button"]}, + {"emoji":"🎦","name":"cinema","category":"Symbols","version":"0.6","skinTone":false,"keywords":["cinema","camera","film","movie"]}, + {"emoji":"🔅","name":"dim button","category":"Symbols","version":"1.0","skinTone":false,"keywords":["dim","button","brightness","low"]}, + {"emoji":"🔆","name":"bright button","category":"Symbols","version":"1.0","skinTone":false,"keywords":["bright","button","brightness","light"]}, + {"emoji":"📶","name":"antenna bars","category":"Symbols","version":"0.6","skinTone":false,"keywords":["antenna","bars","bar","cell","communication","mobile","phone","signal","telephone"]}, + {"emoji":"🛜","name":"wireless","category":"Symbols","version":"15.0","skinTone":false,"keywords":["wireless","broadband","computer","connectivity","hotspot","internet","network","router","smartphone","wi-fi","wifi","wlan"]}, + {"emoji":"📳","name":"vibration mode","category":"Symbols","version":"0.6","skinTone":false,"keywords":["vibration","mode","cell","communication","mobile","phone","telephone"]}, + {"emoji":"📴","name":"mobile phone off","category":"Symbols","version":"0.6","skinTone":false,"keywords":["mobile","phone","off","cell","telephone"]}, + {"emoji":"♀️","name":"female sign","category":"Symbols","version":"4.0","skinTone":false,"keywords":["female","sign"]}, + {"emoji":"♂️","name":"male sign","category":"Symbols","version":"4.0","skinTone":false,"keywords":["male","sign"]}, + {"emoji":"⚧️","name":"transgender symbol","category":"Symbols","version":"13.0","skinTone":false,"keywords":["transgender","symbol"]}, + {"emoji":"✖️","name":"multiply","category":"Symbols","version":"0.6","skinTone":false,"keywords":["multiply"]}, + {"emoji":"➕","name":"plus","category":"Symbols","version":"0.6","skinTone":false,"keywords":["plus","+"]}, + {"emoji":"➖","name":"minus","category":"Symbols","version":"0.6","skinTone":false,"keywords":["minus","-","−","heavy","math","sign"]}, + {"emoji":"➗","name":"divide","category":"Symbols","version":"0.6","skinTone":false,"keywords":["divide","÷","division","heavy","math","sign"]}, + {"emoji":"🟰","name":"heavy equals sign","category":"Symbols","version":"14.0","skinTone":false,"keywords":["heavy","equals","sign","answer","equal","equality","math"]}, + {"emoji":"♾️","name":"infinity","category":"Symbols","version":"11.0","skinTone":false,"keywords":["infinity"]}, + {"emoji":"‼️","name":"double exclamation mark","category":"Symbols","version":"0.6","skinTone":false,"keywords":["double","exclamation","mark"]}, + {"emoji":"⁉️","name":"exclamation question mark","category":"Symbols","version":"0.6","skinTone":false,"keywords":["exclamation","question","mark"]}, + {"emoji":"❓","name":"red question mark","category":"Symbols","version":"0.6","skinTone":false,"keywords":["red","question","mark","?","punctuation"]}, + {"emoji":"❔","name":"white question mark","category":"Symbols","version":"0.6","skinTone":false,"keywords":["white","question","mark","?","outlined","punctuation"]}, + {"emoji":"❕","name":"white exclamation mark","category":"Symbols","version":"0.6","skinTone":false,"keywords":["white","exclamation","mark","!","outlined","punctuation"]}, + {"emoji":"❗","name":"red exclamation mark","category":"Symbols","version":"0.6","skinTone":false,"keywords":["red","exclamation","mark","!","punctuation"]}, + {"emoji":"〰️","name":"wavy dash","category":"Symbols","version":"0.6","skinTone":false,"keywords":["wavy","dash"]}, + {"emoji":"💱","name":"currency exchange","category":"Symbols","version":"0.6","skinTone":false,"keywords":["currency","exchange","bank","money"]}, + {"emoji":"💲","name":"heavy dollar sign","category":"Symbols","version":"0.6","skinTone":false,"keywords":["heavy","dollar","sign","billion","cash","charge","currency","million","money","pay"]}, + {"emoji":"⚕️","name":"medical symbol","category":"Symbols","version":"4.0","skinTone":false,"keywords":["medical","symbol"]}, + {"emoji":"♻️","name":"recycling symbol","category":"Symbols","version":"0.6","skinTone":false,"keywords":["recycling","symbol"]}, + {"emoji":"⚜️","name":"fleur-de-lis","category":"Symbols","version":"1.0","skinTone":false,"keywords":["fleur-de-lis"]}, + {"emoji":"🔱","name":"trident emblem","category":"Symbols","version":"0.6","skinTone":false,"keywords":["trident","emblem","anchor","poseidon","ship","tool"]}, + {"emoji":"📛","name":"name badge","category":"Symbols","version":"0.6","skinTone":false,"keywords":["name","badge"]}, + {"emoji":"🔰","name":"Japanese symbol for beginner","category":"Symbols","version":"0.6","skinTone":false,"keywords":["Japanese","symbol","for","beginner","chevron","green","leaf","tool","yellow"]}, + {"emoji":"⭕","name":"hollow red circle","category":"Symbols","version":"0.6","skinTone":false,"keywords":["hollow","red","circle","heavy","large","o"]}, + {"emoji":"✅","name":"check mark button","category":"Symbols","version":"0.6","skinTone":false,"keywords":["check","mark","button","✓","checked","checkmark","complete","completed","done","fixed","tick"]}, + {"emoji":"☑️","name":"check box with check","category":"Symbols","version":"0.6","skinTone":false,"keywords":["check","box","with"]}, + {"emoji":"✔️","name":"check mark","category":"Symbols","version":"0.6","skinTone":false,"keywords":["check","mark"]}, + {"emoji":"❌","name":"cross mark","category":"Symbols","version":"0.6","skinTone":false,"keywords":["cross","mark","×","cancel","multiplication","multiply","x"]}, + {"emoji":"❎","name":"cross mark button","category":"Symbols","version":"0.6","skinTone":false,"keywords":["cross","mark","button","×","multiplication","multiply","square","x"]}, + {"emoji":"➰","name":"curly loop","category":"Symbols","version":"0.6","skinTone":false,"keywords":["curly","loop","curl"]}, + {"emoji":"➿","name":"double curly loop","category":"Symbols","version":"1.0","skinTone":false,"keywords":["double","curly","loop","curl"]}, + {"emoji":"〽️","name":"part alternation mark","category":"Symbols","version":"0.6","skinTone":false,"keywords":["part","alternation","mark"]}, + {"emoji":"✳️","name":"eight-spoked asterisk","category":"Symbols","version":"0.6","skinTone":false,"keywords":["eight-spoked","asterisk"]}, + {"emoji":"✴️","name":"eight-pointed star","category":"Symbols","version":"0.6","skinTone":false,"keywords":["eight-pointed","star"]}, + {"emoji":"❇️","name":"sparkle","category":"Symbols","version":"0.6","skinTone":false,"keywords":["sparkle"]}, + {"emoji":"©️","name":"copyright","category":"Symbols","version":"0.6","skinTone":false,"keywords":["copyright"]}, + {"emoji":"®️","name":"registered","category":"Symbols","version":"0.6","skinTone":false,"keywords":["registered"]}, + {"emoji":"™️","name":"trade mark","category":"Symbols","version":"0.6","skinTone":false,"keywords":["trade","mark"]}, + {"emoji":"🫟","name":"splatter","category":"Symbols","version":"16.0","skinTone":false,"keywords":["splatter","drip","holi","ink","liquid","mess","paint","spill","stain"]}, + {"emoji":"#️⃣","name":"keycap: #","category":"Symbols","version":"0.6","skinTone":false,"keywords":["keycap:","#"]}, + {"emoji":"*️⃣","name":"keycap: *","category":"Symbols","version":"2.0","skinTone":false,"keywords":["keycap:","*"]}, + {"emoji":"0️⃣","name":"keycap: 0","category":"Symbols","version":"0.6","skinTone":false,"keywords":["keycap:","0"]}, + {"emoji":"1️⃣","name":"keycap: 1","category":"Symbols","version":"0.6","skinTone":false,"keywords":["keycap:","1"]}, + {"emoji":"2️⃣","name":"keycap: 2","category":"Symbols","version":"0.6","skinTone":false,"keywords":["keycap:","2"]}, + {"emoji":"3️⃣","name":"keycap: 3","category":"Symbols","version":"0.6","skinTone":false,"keywords":["keycap:","3"]}, + {"emoji":"4️⃣","name":"keycap: 4","category":"Symbols","version":"0.6","skinTone":false,"keywords":["keycap:","4"]}, + {"emoji":"5️⃣","name":"keycap: 5","category":"Symbols","version":"0.6","skinTone":false,"keywords":["keycap:","5"]}, + {"emoji":"6️⃣","name":"keycap: 6","category":"Symbols","version":"0.6","skinTone":false,"keywords":["keycap:","6"]}, + {"emoji":"7️⃣","name":"keycap: 7","category":"Symbols","version":"0.6","skinTone":false,"keywords":["keycap:","7"]}, + {"emoji":"8️⃣","name":"keycap: 8","category":"Symbols","version":"0.6","skinTone":false,"keywords":["keycap:","8"]}, + {"emoji":"9️⃣","name":"keycap: 9","category":"Symbols","version":"0.6","skinTone":false,"keywords":["keycap:","9"]}, + {"emoji":"🔟","name":"keycap: 10","category":"Symbols","version":"0.6","skinTone":false,"keywords":["keycap:","10"]}, + {"emoji":"🔠","name":"input latin uppercase","category":"Symbols","version":"0.6","skinTone":false,"keywords":["input","latin","uppercase","ABCD","letters"]}, + {"emoji":"🔡","name":"input latin lowercase","category":"Symbols","version":"0.6","skinTone":false,"keywords":["input","latin","lowercase","abcd","letters"]}, + {"emoji":"🔢","name":"input numbers","category":"Symbols","version":"0.6","skinTone":false,"keywords":["input","numbers","1234"]}, + {"emoji":"🔣","name":"input symbols","category":"Symbols","version":"0.6","skinTone":false,"keywords":["input","symbols","&","%","♪","〒"]}, + {"emoji":"🔤","name":"input latin letters","category":"Symbols","version":"0.6","skinTone":false,"keywords":["input","latin","letters","abc","alphabet"]}, + {"emoji":"🅰️","name":"A button (blood type)","category":"Symbols","version":"0.6","skinTone":false,"keywords":["A","button","(blood","type)"]}, + {"emoji":"🆎","name":"AB button (blood type)","category":"Symbols","version":"0.6","skinTone":false,"keywords":["AB","button","(blood","type)","blood","type"]}, + {"emoji":"🅱️","name":"B button (blood type)","category":"Symbols","version":"0.6","skinTone":false,"keywords":["B","button","(blood","type)"]}, + {"emoji":"🆑","name":"CL button","category":"Symbols","version":"0.6","skinTone":false,"keywords":["CL","button"]}, + {"emoji":"🆒","name":"COOL button","category":"Symbols","version":"0.6","skinTone":false,"keywords":["COOL","button"]}, + {"emoji":"🆓","name":"FREE button","category":"Symbols","version":"0.6","skinTone":false,"keywords":["FREE","button"]}, + {"emoji":"ℹ️","name":"information","category":"Symbols","version":"0.6","skinTone":false,"keywords":["information"]}, + {"emoji":"🆔","name":"ID button","category":"Symbols","version":"0.6","skinTone":false,"keywords":["ID","button","identity"]}, + {"emoji":"Ⓜ️","name":"circled M","category":"Symbols","version":"0.6","skinTone":false,"keywords":["circled","M"]}, + {"emoji":"🆕","name":"NEW button","category":"Symbols","version":"0.6","skinTone":false,"keywords":["NEW","button"]}, + {"emoji":"🆖","name":"NG button","category":"Symbols","version":"0.6","skinTone":false,"keywords":["NG","button"]}, + {"emoji":"🅾️","name":"O button (blood type)","category":"Symbols","version":"0.6","skinTone":false,"keywords":["O","button","(blood","type)"]}, + {"emoji":"🆗","name":"OK button","category":"Symbols","version":"0.6","skinTone":false,"keywords":["OK","button","okay"]}, + {"emoji":"🅿️","name":"P button","category":"Symbols","version":"0.6","skinTone":false,"keywords":["P","button"]}, + {"emoji":"🆘","name":"SOS button","category":"Symbols","version":"0.6","skinTone":false,"keywords":["SOS","button","help"]}, + {"emoji":"🆙","name":"UP! button","category":"Symbols","version":"0.6","skinTone":false,"keywords":["UP!","button","mark","UP"]}, + {"emoji":"🆚","name":"VS button","category":"Symbols","version":"0.6","skinTone":false,"keywords":["VS","button","versus"]}, + {"emoji":"🈁","name":"Japanese “here” button","category":"Symbols","version":"0.6","skinTone":false,"keywords":["Japanese","“here”","button","here","katakana"]}, + {"emoji":"🈂️","name":"Japanese “service charge” button","category":"Symbols","version":"0.6","skinTone":false,"keywords":["Japanese","“service","charge”","button"]}, + {"emoji":"🈷️","name":"Japanese “monthly amount” button","category":"Symbols","version":"0.6","skinTone":false,"keywords":["Japanese","“monthly","amount”","button"]}, + {"emoji":"🈶","name":"Japanese “not free of charge” button","category":"Symbols","version":"0.6","skinTone":false,"keywords":["Japanese","“not","free","of","charge”","button","charge","ideograph","not"]}, + {"emoji":"🈯","name":"Japanese “reserved” button","category":"Symbols","version":"0.6","skinTone":false,"keywords":["Japanese","“reserved”","button","ideograph","reserved"]}, + {"emoji":"🉐","name":"Japanese “bargain” button","category":"Symbols","version":"0.6","skinTone":false,"keywords":["Japanese","“bargain”","button","bargain","ideograph"]}, + {"emoji":"🈹","name":"Japanese “discount” button","category":"Symbols","version":"0.6","skinTone":false,"keywords":["Japanese","“discount”","button","discount","ideograph"]}, + {"emoji":"🈚","name":"Japanese “free of charge” button","category":"Symbols","version":"0.6","skinTone":false,"keywords":["Japanese","“free","of","charge”","button","charge","free","ideograph"]}, + {"emoji":"🈲","name":"Japanese “prohibited” button","category":"Symbols","version":"0.6","skinTone":false,"keywords":["Japanese","“prohibited”","button","ideograph","prohibited"]}, + {"emoji":"🉑","name":"Japanese “acceptable” button","category":"Symbols","version":"0.6","skinTone":false,"keywords":["Japanese","“acceptable”","button","acceptable","ideograph"]}, + {"emoji":"🈸","name":"Japanese “application” button","category":"Symbols","version":"0.6","skinTone":false,"keywords":["Japanese","“application”","button","application","ideograph"]}, + {"emoji":"🈴","name":"Japanese “passing grade” button","category":"Symbols","version":"0.6","skinTone":false,"keywords":["Japanese","“passing","grade”","button","grade","ideograph","passing"]}, + {"emoji":"🈳","name":"Japanese “vacancy” button","category":"Symbols","version":"0.6","skinTone":false,"keywords":["Japanese","“vacancy”","button","ideograph","vacancy"]}, + {"emoji":"㊗️","name":"Japanese “congratulations” button","category":"Symbols","version":"0.6","skinTone":false,"keywords":["Japanese","“congratulations”","button"]}, + {"emoji":"㊙️","name":"Japanese “secret” button","category":"Symbols","version":"0.6","skinTone":false,"keywords":["Japanese","“secret”","button"]}, + {"emoji":"🈺","name":"Japanese “open for business” button","category":"Symbols","version":"0.6","skinTone":false,"keywords":["Japanese","“open","for","business”","button","business","ideograph","open"]}, + {"emoji":"🈵","name":"Japanese “no vacancy” button","category":"Symbols","version":"0.6","skinTone":false,"keywords":["Japanese","“no","vacancy”","button","ideograph","no","vacancy"]}, + {"emoji":"🔴","name":"red circle","category":"Symbols","version":"0.6","skinTone":false,"keywords":["red","circle","geometric"]}, + {"emoji":"🟠","name":"orange circle","category":"Symbols","version":"12.0","skinTone":false,"keywords":["orange","circle"]}, + {"emoji":"🟡","name":"yellow circle","category":"Symbols","version":"12.0","skinTone":false,"keywords":["yellow","circle"]}, + {"emoji":"🟢","name":"green circle","category":"Symbols","version":"12.0","skinTone":false,"keywords":["green","circle"]}, + {"emoji":"🔵","name":"blue circle","category":"Symbols","version":"0.6","skinTone":false,"keywords":["blue","circle","geometric"]}, + {"emoji":"🟣","name":"purple circle","category":"Symbols","version":"12.0","skinTone":false,"keywords":["purple","circle"]}, + {"emoji":"🟤","name":"brown circle","category":"Symbols","version":"12.0","skinTone":false,"keywords":["brown","circle"]}, + {"emoji":"⚫","name":"black circle","category":"Symbols","version":"0.6","skinTone":false,"keywords":["black","circle","geometric"]}, + {"emoji":"⚪","name":"white circle","category":"Symbols","version":"0.6","skinTone":false,"keywords":["white","circle","geometric"]}, + {"emoji":"🟥","name":"red square","category":"Symbols","version":"12.0","skinTone":false,"keywords":["red","square","card","penalty"]}, + {"emoji":"🟧","name":"orange square","category":"Symbols","version":"12.0","skinTone":false,"keywords":["orange","square"]}, + {"emoji":"🟨","name":"yellow square","category":"Symbols","version":"12.0","skinTone":false,"keywords":["yellow","square","card","penalty"]}, + {"emoji":"🟩","name":"green square","category":"Symbols","version":"12.0","skinTone":false,"keywords":["green","square"]}, + {"emoji":"🟦","name":"blue square","category":"Symbols","version":"12.0","skinTone":false,"keywords":["blue","square"]}, + {"emoji":"🟪","name":"purple square","category":"Symbols","version":"12.0","skinTone":false,"keywords":["purple","square"]}, + {"emoji":"🟫","name":"brown square","category":"Symbols","version":"12.0","skinTone":false,"keywords":["brown","square"]}, + {"emoji":"⬛","name":"black large square","category":"Symbols","version":"0.6","skinTone":false,"keywords":["black","large","square","geometric"]}, + {"emoji":"⬜","name":"white large square","category":"Symbols","version":"0.6","skinTone":false,"keywords":["white","large","square","geometric"]}, + {"emoji":"◼️","name":"black medium square","category":"Symbols","version":"0.6","skinTone":false,"keywords":["black","medium","square"]}, + {"emoji":"◻️","name":"white medium square","category":"Symbols","version":"0.6","skinTone":false,"keywords":["white","medium","square"]}, + {"emoji":"◾","name":"black medium-small square","category":"Symbols","version":"0.6","skinTone":false,"keywords":["black","medium-small","square","geometric"]}, + {"emoji":"◽","name":"white medium-small square","category":"Symbols","version":"0.6","skinTone":false,"keywords":["white","medium-small","square","geometric"]}, + {"emoji":"▪️","name":"black small square","category":"Symbols","version":"0.6","skinTone":false,"keywords":["black","small","square"]}, + {"emoji":"▫️","name":"white small square","category":"Symbols","version":"0.6","skinTone":false,"keywords":["white","small","square"]}, + {"emoji":"🔶","name":"large orange diamond","category":"Symbols","version":"0.6","skinTone":false,"keywords":["large","orange","diamond","geometric"]}, + {"emoji":"🔷","name":"large blue diamond","category":"Symbols","version":"0.6","skinTone":false,"keywords":["large","blue","diamond","geometric"]}, + {"emoji":"🔸","name":"small orange diamond","category":"Symbols","version":"0.6","skinTone":false,"keywords":["small","orange","diamond","geometric"]}, + {"emoji":"🔹","name":"small blue diamond","category":"Symbols","version":"0.6","skinTone":false,"keywords":["small","blue","diamond","geometric"]}, + {"emoji":"🔺","name":"red triangle pointed up","category":"Symbols","version":"0.6","skinTone":false,"keywords":["red","triangle","pointed","up","geometric"]}, + {"emoji":"🔻","name":"red triangle pointed down","category":"Symbols","version":"0.6","skinTone":false,"keywords":["red","triangle","pointed","down","geometric"]}, + {"emoji":"💠","name":"diamond with a dot","category":"Symbols","version":"0.6","skinTone":false,"keywords":["diamond","with","a","dot","comic","geometric"]}, + {"emoji":"🔘","name":"radio button","category":"Symbols","version":"0.6","skinTone":false,"keywords":["radio","button","geometric"]}, + {"emoji":"🔳","name":"white square button","category":"Symbols","version":"0.6","skinTone":false,"keywords":["white","square","button","geometric","outlined"]}, + {"emoji":"🔲","name":"black square button","category":"Symbols","version":"0.6","skinTone":false,"keywords":["black","square","button","geometric"]}, + {"emoji":"🏁","name":"chequered flag","category":"Flags","version":"0.6","skinTone":false,"keywords":["chequered","flag","checkered","finish","flags","game","race","racing","sport","win"]}, + {"emoji":"🚩","name":"triangular flag","category":"Flags","version":"0.6","skinTone":false,"keywords":["triangular","flag","construction","golf","post"]}, + {"emoji":"🎌","name":"crossed flags","category":"Flags","version":"0.6","skinTone":false,"keywords":["crossed","flags","celebration","cross","Japanese"]}, + {"emoji":"🏴","name":"black flag","category":"Flags","version":"1.0","skinTone":false,"keywords":["black","flag","waving"]}, + {"emoji":"🏳️","name":"white flag","category":"Flags","version":"0.7","skinTone":false,"keywords":["white","flag"]}, + {"emoji":"🏳️‍🌈","name":"rainbow flag","category":"Flags","version":"4.0","skinTone":false,"keywords":["rainbow","flag"]}, + {"emoji":"🏳️‍⚧️","name":"transgender flag","category":"Flags","version":"13.0","skinTone":false,"keywords":["transgender","flag"]}, + {"emoji":"🏴‍☠️","name":"pirate flag","category":"Flags","version":"11.0","skinTone":false,"keywords":["pirate","flag"]}, + {"emoji":"🇦🇨","name":"flag: Ascension Island","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Ascension","Island"]}, + {"emoji":"🇦🇩","name":"flag: Andorra","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Andorra"]}, + {"emoji":"🇦🇪","name":"flag: United Arab Emirates","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","United","Arab","Emirates"]}, + {"emoji":"🇦🇫","name":"flag: Afghanistan","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Afghanistan"]}, + {"emoji":"🇦🇬","name":"flag: Antigua & Barbuda","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Antigua","&","Barbuda"]}, + {"emoji":"🇦🇮","name":"flag: Anguilla","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Anguilla"]}, + {"emoji":"🇦🇱","name":"flag: Albania","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Albania"]}, + {"emoji":"🇦🇲","name":"flag: Armenia","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Armenia"]}, + {"emoji":"🇦🇴","name":"flag: Angola","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Angola"]}, + {"emoji":"🇦🇶","name":"flag: Antarctica","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Antarctica"]}, + {"emoji":"🇦🇷","name":"flag: Argentina","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Argentina"]}, + {"emoji":"🇦🇸","name":"flag: American Samoa","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","American","Samoa"]}, + {"emoji":"🇦🇹","name":"flag: Austria","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Austria"]}, + {"emoji":"🇦🇺","name":"flag: Australia","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Australia"]}, + {"emoji":"🇦🇼","name":"flag: Aruba","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Aruba"]}, + {"emoji":"🇦🇽","name":"flag: Åland Islands","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Åland","Islands"]}, + {"emoji":"🇦🇿","name":"flag: Azerbaijan","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Azerbaijan"]}, + {"emoji":"🇧🇦","name":"flag: Bosnia & Herzegovina","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Bosnia","&","Herzegovina"]}, + {"emoji":"🇧🇧","name":"flag: Barbados","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Barbados"]}, + {"emoji":"🇧🇩","name":"flag: Bangladesh","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Bangladesh"]}, + {"emoji":"🇧🇪","name":"flag: Belgium","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Belgium"]}, + {"emoji":"🇧🇫","name":"flag: Burkina Faso","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Burkina","Faso"]}, + {"emoji":"🇧🇬","name":"flag: Bulgaria","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Bulgaria"]}, + {"emoji":"🇧🇭","name":"flag: Bahrain","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Bahrain"]}, + {"emoji":"🇧🇮","name":"flag: Burundi","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Burundi"]}, + {"emoji":"🇧🇯","name":"flag: Benin","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Benin"]}, + {"emoji":"🇧🇱","name":"flag: St. Barthélemy","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","St.","Barthélemy"]}, + {"emoji":"🇧🇲","name":"flag: Bermuda","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Bermuda"]}, + {"emoji":"🇧🇳","name":"flag: Brunei","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Brunei"]}, + {"emoji":"🇧🇴","name":"flag: Bolivia","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Bolivia"]}, + {"emoji":"🇧🇶","name":"flag: Caribbean Netherlands","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Caribbean","Netherlands"]}, + {"emoji":"🇧🇷","name":"flag: Brazil","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Brazil"]}, + {"emoji":"🇧🇸","name":"flag: Bahamas","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Bahamas"]}, + {"emoji":"🇧🇹","name":"flag: Bhutan","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Bhutan"]}, + {"emoji":"🇧🇻","name":"flag: Bouvet Island","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Bouvet","Island"]}, + {"emoji":"🇧🇼","name":"flag: Botswana","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Botswana"]}, + {"emoji":"🇧🇾","name":"flag: Belarus","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Belarus"]}, + {"emoji":"🇧🇿","name":"flag: Belize","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Belize"]}, + {"emoji":"🇨🇦","name":"flag: Canada","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Canada"]}, + {"emoji":"🇨🇨","name":"flag: Cocos (Keeling) Islands","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Cocos","(Keeling)","Islands"]}, + {"emoji":"🇨🇩","name":"flag: Congo - Kinshasa","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Congo","-","Kinshasa"]}, + {"emoji":"🇨🇫","name":"flag: Central African Republic","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Central","African","Republic"]}, + {"emoji":"🇨🇬","name":"flag: Congo - Brazzaville","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Congo","-","Brazzaville"]}, + {"emoji":"🇨🇭","name":"flag: Switzerland","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Switzerland"]}, + {"emoji":"🇨🇮","name":"flag: Côte d’Ivoire","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Côte","d’Ivoire"]}, + {"emoji":"🇨🇰","name":"flag: Cook Islands","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Cook","Islands"]}, + {"emoji":"🇨🇱","name":"flag: Chile","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Chile"]}, + {"emoji":"🇨🇲","name":"flag: Cameroon","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Cameroon"]}, + {"emoji":"🇨🇳","name":"flag: China","category":"Flags","version":"0.6","skinTone":false,"keywords":["flag:","China"]}, + {"emoji":"🇨🇴","name":"flag: Colombia","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Colombia"]}, + {"emoji":"🇨🇵","name":"flag: Clipperton Island","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Clipperton","Island"]}, + {"emoji":"🇨🇶","name":"flag: Sark","category":"Flags","version":"16.0","skinTone":false,"keywords":["flag:","Sark"]}, + {"emoji":"🇨🇷","name":"flag: Costa Rica","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Costa","Rica"]}, + {"emoji":"🇨🇺","name":"flag: Cuba","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Cuba"]}, + {"emoji":"🇨🇻","name":"flag: Cape Verde","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Cape","Verde"]}, + {"emoji":"🇨🇼","name":"flag: Curaçao","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Curaçao"]}, + {"emoji":"🇨🇽","name":"flag: Christmas Island","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Christmas","Island"]}, + {"emoji":"🇨🇾","name":"flag: Cyprus","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Cyprus"]}, + {"emoji":"🇨🇿","name":"flag: Czechia","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Czechia"]}, + {"emoji":"🇩🇪","name":"flag: Germany","category":"Flags","version":"0.6","skinTone":false,"keywords":["flag:","Germany"]}, + {"emoji":"🇩🇬","name":"flag: Diego Garcia","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Diego","Garcia"]}, + {"emoji":"🇩🇯","name":"flag: Djibouti","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Djibouti"]}, + {"emoji":"🇩🇰","name":"flag: Denmark","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Denmark"]}, + {"emoji":"🇩🇲","name":"flag: Dominica","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Dominica"]}, + {"emoji":"🇩🇴","name":"flag: Dominican Republic","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Dominican","Republic"]}, + {"emoji":"🇩🇿","name":"flag: Algeria","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Algeria"]}, + {"emoji":"🇪🇦","name":"flag: Ceuta & Melilla","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Ceuta","&","Melilla"]}, + {"emoji":"🇪🇨","name":"flag: Ecuador","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Ecuador"]}, + {"emoji":"🇪🇪","name":"flag: Estonia","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Estonia"]}, + {"emoji":"🇪🇬","name":"flag: Egypt","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Egypt"]}, + {"emoji":"🇪🇭","name":"flag: Western Sahara","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Western","Sahara"]}, + {"emoji":"🇪🇷","name":"flag: Eritrea","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Eritrea"]}, + {"emoji":"🇪🇸","name":"flag: Spain","category":"Flags","version":"0.6","skinTone":false,"keywords":["flag:","Spain"]}, + {"emoji":"🇪🇹","name":"flag: Ethiopia","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Ethiopia"]}, + {"emoji":"🇪🇺","name":"flag: European Union","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","European","Union"]}, + {"emoji":"🇫🇮","name":"flag: Finland","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Finland"]}, + {"emoji":"🇫🇯","name":"flag: Fiji","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Fiji"]}, + {"emoji":"🇫🇰","name":"flag: Falkland Islands","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Falkland","Islands"]}, + {"emoji":"🇫🇲","name":"flag: Micronesia","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Micronesia"]}, + {"emoji":"🇫🇴","name":"flag: Faroe Islands","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Faroe","Islands"]}, + {"emoji":"🇫🇷","name":"flag: France","category":"Flags","version":"0.6","skinTone":false,"keywords":["flag:","France"]}, + {"emoji":"🇬🇦","name":"flag: Gabon","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Gabon"]}, + {"emoji":"🇬🇧","name":"flag: United Kingdom","category":"Flags","version":"0.6","skinTone":false,"keywords":["flag:","United","Kingdom"]}, + {"emoji":"🇬🇩","name":"flag: Grenada","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Grenada"]}, + {"emoji":"🇬🇪","name":"flag: Georgia","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Georgia"]}, + {"emoji":"🇬🇫","name":"flag: French Guiana","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","French","Guiana"]}, + {"emoji":"🇬🇬","name":"flag: Guernsey","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Guernsey"]}, + {"emoji":"🇬🇭","name":"flag: Ghana","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Ghana"]}, + {"emoji":"🇬🇮","name":"flag: Gibraltar","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Gibraltar"]}, + {"emoji":"🇬🇱","name":"flag: Greenland","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Greenland"]}, + {"emoji":"🇬🇲","name":"flag: Gambia","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Gambia"]}, + {"emoji":"🇬🇳","name":"flag: Guinea","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Guinea"]}, + {"emoji":"🇬🇵","name":"flag: Guadeloupe","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Guadeloupe"]}, + {"emoji":"🇬🇶","name":"flag: Equatorial Guinea","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Equatorial","Guinea"]}, + {"emoji":"🇬🇷","name":"flag: Greece","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Greece"]}, + {"emoji":"🇬🇸","name":"flag: South Georgia & South Sandwich Islands","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","South","Georgia","&","Sandwich","Islands"]}, + {"emoji":"🇬🇹","name":"flag: Guatemala","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Guatemala"]}, + {"emoji":"🇬🇺","name":"flag: Guam","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Guam"]}, + {"emoji":"🇬🇼","name":"flag: Guinea-Bissau","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Guinea-Bissau"]}, + {"emoji":"🇬🇾","name":"flag: Guyana","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Guyana"]}, + {"emoji":"🇭🇰","name":"flag: Hong Kong SAR China","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Hong","Kong","SAR","China"]}, + {"emoji":"🇭🇲","name":"flag: Heard & McDonald Islands","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Heard","&","McDonald","Islands"]}, + {"emoji":"🇭🇳","name":"flag: Honduras","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Honduras"]}, + {"emoji":"🇭🇷","name":"flag: Croatia","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Croatia"]}, + {"emoji":"🇭🇹","name":"flag: Haiti","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Haiti"]}, + {"emoji":"🇭🇺","name":"flag: Hungary","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Hungary"]}, + {"emoji":"🇮🇨","name":"flag: Canary Islands","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Canary","Islands"]}, + {"emoji":"🇮🇩","name":"flag: Indonesia","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Indonesia"]}, + {"emoji":"🇮🇪","name":"flag: Ireland","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Ireland"]}, + {"emoji":"🇮🇱","name":"flag: Israel","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Israel"]}, + {"emoji":"🇮🇲","name":"flag: Isle of Man","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Isle","of","Man"]}, + {"emoji":"🇮🇳","name":"flag: India","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","India"]}, + {"emoji":"🇮🇴","name":"flag: British Indian Ocean Territory","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","British","Indian","Ocean","Territory"]}, + {"emoji":"🇮🇶","name":"flag: Iraq","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Iraq"]}, + {"emoji":"🇮🇷","name":"flag: Iran","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Iran"]}, + {"emoji":"🇮🇸","name":"flag: Iceland","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Iceland"]}, + {"emoji":"🇮🇹","name":"flag: Italy","category":"Flags","version":"0.6","skinTone":false,"keywords":["flag:","Italy"]}, + {"emoji":"🇯🇪","name":"flag: Jersey","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Jersey"]}, + {"emoji":"🇯🇲","name":"flag: Jamaica","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Jamaica"]}, + {"emoji":"🇯🇴","name":"flag: Jordan","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Jordan"]}, + {"emoji":"🇯🇵","name":"flag: Japan","category":"Flags","version":"0.6","skinTone":false,"keywords":["flag:","Japan"]}, + {"emoji":"🇰🇪","name":"flag: Kenya","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Kenya"]}, + {"emoji":"🇰🇬","name":"flag: Kyrgyzstan","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Kyrgyzstan"]}, + {"emoji":"🇰🇭","name":"flag: Cambodia","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Cambodia"]}, + {"emoji":"🇰🇮","name":"flag: Kiribati","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Kiribati"]}, + {"emoji":"🇰🇲","name":"flag: Comoros","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Comoros"]}, + {"emoji":"🇰🇳","name":"flag: St. Kitts & Nevis","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","St.","Kitts","&","Nevis"]}, + {"emoji":"🇰🇵","name":"flag: North Korea","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","North","Korea"]}, + {"emoji":"🇰🇷","name":"flag: South Korea","category":"Flags","version":"0.6","skinTone":false,"keywords":["flag:","South","Korea"]}, + {"emoji":"🇰🇼","name":"flag: Kuwait","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Kuwait"]}, + {"emoji":"🇰🇾","name":"flag: Cayman Islands","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Cayman","Islands"]}, + {"emoji":"🇰🇿","name":"flag: Kazakhstan","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Kazakhstan"]}, + {"emoji":"🇱🇦","name":"flag: Laos","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Laos"]}, + {"emoji":"🇱🇧","name":"flag: Lebanon","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Lebanon"]}, + {"emoji":"🇱🇨","name":"flag: St. Lucia","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","St.","Lucia"]}, + {"emoji":"🇱🇮","name":"flag: Liechtenstein","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Liechtenstein"]}, + {"emoji":"🇱🇰","name":"flag: Sri Lanka","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Sri","Lanka"]}, + {"emoji":"🇱🇷","name":"flag: Liberia","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Liberia"]}, + {"emoji":"🇱🇸","name":"flag: Lesotho","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Lesotho"]}, + {"emoji":"🇱🇹","name":"flag: Lithuania","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Lithuania"]}, + {"emoji":"🇱🇺","name":"flag: Luxembourg","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Luxembourg"]}, + {"emoji":"🇱🇻","name":"flag: Latvia","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Latvia"]}, + {"emoji":"🇱🇾","name":"flag: Libya","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Libya"]}, + {"emoji":"🇲🇦","name":"flag: Morocco","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Morocco"]}, + {"emoji":"🇲🇨","name":"flag: Monaco","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Monaco"]}, + {"emoji":"🇲🇩","name":"flag: Moldova","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Moldova"]}, + {"emoji":"🇲🇪","name":"flag: Montenegro","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Montenegro"]}, + {"emoji":"🇲🇫","name":"flag: St. Martin","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","St.","Martin"]}, + {"emoji":"🇲🇬","name":"flag: Madagascar","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Madagascar"]}, + {"emoji":"🇲🇭","name":"flag: Marshall Islands","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Marshall","Islands"]}, + {"emoji":"🇲🇰","name":"flag: North Macedonia","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","North","Macedonia"]}, + {"emoji":"🇲🇱","name":"flag: Mali","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Mali"]}, + {"emoji":"🇲🇲","name":"flag: Myanmar (Burma)","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Myanmar","(Burma)"]}, + {"emoji":"🇲🇳","name":"flag: Mongolia","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Mongolia"]}, + {"emoji":"🇲🇴","name":"flag: Macao SAR China","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Macao","SAR","China"]}, + {"emoji":"🇲🇵","name":"flag: Northern Mariana Islands","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Northern","Mariana","Islands"]}, + {"emoji":"🇲🇶","name":"flag: Martinique","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Martinique"]}, + {"emoji":"🇲🇷","name":"flag: Mauritania","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Mauritania"]}, + {"emoji":"🇲🇸","name":"flag: Montserrat","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Montserrat"]}, + {"emoji":"🇲🇹","name":"flag: Malta","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Malta"]}, + {"emoji":"🇲🇺","name":"flag: Mauritius","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Mauritius"]}, + {"emoji":"🇲🇻","name":"flag: Maldives","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Maldives"]}, + {"emoji":"🇲🇼","name":"flag: Malawi","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Malawi"]}, + {"emoji":"🇲🇽","name":"flag: Mexico","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Mexico"]}, + {"emoji":"🇲🇾","name":"flag: Malaysia","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Malaysia"]}, + {"emoji":"🇲🇿","name":"flag: Mozambique","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Mozambique"]}, + {"emoji":"🇳🇦","name":"flag: Namibia","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Namibia"]}, + {"emoji":"🇳🇨","name":"flag: New Caledonia","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","New","Caledonia"]}, + {"emoji":"🇳🇪","name":"flag: Niger","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Niger"]}, + {"emoji":"🇳🇫","name":"flag: Norfolk Island","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Norfolk","Island"]}, + {"emoji":"🇳🇬","name":"flag: Nigeria","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Nigeria"]}, + {"emoji":"🇳🇮","name":"flag: Nicaragua","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Nicaragua"]}, + {"emoji":"🇳🇱","name":"flag: Netherlands","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Netherlands"]}, + {"emoji":"🇳🇴","name":"flag: Norway","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Norway"]}, + {"emoji":"🇳🇵","name":"flag: Nepal","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Nepal"]}, + {"emoji":"🇳🇷","name":"flag: Nauru","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Nauru"]}, + {"emoji":"🇳🇺","name":"flag: Niue","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Niue"]}, + {"emoji":"🇳🇿","name":"flag: New Zealand","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","New","Zealand"]}, + {"emoji":"🇴🇲","name":"flag: Oman","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Oman"]}, + {"emoji":"🇵🇦","name":"flag: Panama","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Panama"]}, + {"emoji":"🇵🇪","name":"flag: Peru","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Peru"]}, + {"emoji":"🇵🇫","name":"flag: French Polynesia","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","French","Polynesia"]}, + {"emoji":"🇵🇬","name":"flag: Papua New Guinea","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Papua","New","Guinea"]}, + {"emoji":"🇵🇭","name":"flag: Philippines","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Philippines"]}, + {"emoji":"🇵🇰","name":"flag: Pakistan","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Pakistan"]}, + {"emoji":"🇵🇱","name":"flag: Poland","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Poland"]}, + {"emoji":"🇵🇲","name":"flag: St. Pierre & Miquelon","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","St.","Pierre","&","Miquelon"]}, + {"emoji":"🇵🇳","name":"flag: Pitcairn Islands","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Pitcairn","Islands"]}, + {"emoji":"🇵🇷","name":"flag: Puerto Rico","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Puerto","Rico"]}, + {"emoji":"🇵🇸","name":"flag: Palestinian Territories","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Palestinian","Territories"]}, + {"emoji":"🇵🇹","name":"flag: Portugal","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Portugal"]}, + {"emoji":"🇵🇼","name":"flag: Palau","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Palau"]}, + {"emoji":"🇵🇾","name":"flag: Paraguay","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Paraguay"]}, + {"emoji":"🇶🇦","name":"flag: Qatar","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Qatar"]}, + {"emoji":"🇷🇪","name":"flag: Réunion","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Réunion"]}, + {"emoji":"🇷🇴","name":"flag: Romania","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Romania"]}, + {"emoji":"🇷🇸","name":"flag: Serbia","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Serbia"]}, + {"emoji":"🇷🇺","name":"flag: Russia","category":"Flags","version":"0.6","skinTone":false,"keywords":["flag:","Russia"]}, + {"emoji":"🇷🇼","name":"flag: Rwanda","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Rwanda"]}, + {"emoji":"🇸🇦","name":"flag: Saudi Arabia","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Saudi","Arabia"]}, + {"emoji":"🇸🇧","name":"flag: Solomon Islands","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Solomon","Islands"]}, + {"emoji":"🇸🇨","name":"flag: Seychelles","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Seychelles"]}, + {"emoji":"🇸🇩","name":"flag: Sudan","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Sudan"]}, + {"emoji":"🇸🇪","name":"flag: Sweden","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Sweden"]}, + {"emoji":"🇸🇬","name":"flag: Singapore","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Singapore"]}, + {"emoji":"🇸🇭","name":"flag: St. Helena","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","St.","Helena"]}, + {"emoji":"🇸🇮","name":"flag: Slovenia","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Slovenia"]}, + {"emoji":"🇸🇯","name":"flag: Svalbard & Jan Mayen","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Svalbard","&","Jan","Mayen"]}, + {"emoji":"🇸🇰","name":"flag: Slovakia","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Slovakia"]}, + {"emoji":"🇸🇱","name":"flag: Sierra Leone","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Sierra","Leone"]}, + {"emoji":"🇸🇲","name":"flag: San Marino","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","San","Marino"]}, + {"emoji":"🇸🇳","name":"flag: Senegal","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Senegal"]}, + {"emoji":"🇸🇴","name":"flag: Somalia","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Somalia"]}, + {"emoji":"🇸🇷","name":"flag: Suriname","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Suriname"]}, + {"emoji":"🇸🇸","name":"flag: South Sudan","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","South","Sudan"]}, + {"emoji":"🇸🇹","name":"flag: São Tomé & Príncipe","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","São","Tomé","&","Príncipe"]}, + {"emoji":"🇸🇻","name":"flag: El Salvador","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","El","Salvador"]}, + {"emoji":"🇸🇽","name":"flag: Sint Maarten","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Sint","Maarten"]}, + {"emoji":"🇸🇾","name":"flag: Syria","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Syria"]}, + {"emoji":"🇸🇿","name":"flag: Eswatini","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Eswatini"]}, + {"emoji":"🇹🇦","name":"flag: Tristan da Cunha","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Tristan","da","Cunha"]}, + {"emoji":"🇹🇨","name":"flag: Turks & Caicos Islands","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Turks","&","Caicos","Islands"]}, + {"emoji":"🇹🇩","name":"flag: Chad","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Chad"]}, + {"emoji":"🇹🇫","name":"flag: French Southern Territories","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","French","Southern","Territories"]}, + {"emoji":"🇹🇬","name":"flag: Togo","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Togo"]}, + {"emoji":"🇹🇭","name":"flag: Thailand","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Thailand"]}, + {"emoji":"🇹🇯","name":"flag: Tajikistan","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Tajikistan"]}, + {"emoji":"🇹🇰","name":"flag: Tokelau","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Tokelau"]}, + {"emoji":"🇹🇱","name":"flag: Timor-Leste","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Timor-Leste"]}, + {"emoji":"🇹🇲","name":"flag: Turkmenistan","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Turkmenistan"]}, + {"emoji":"🇹🇳","name":"flag: Tunisia","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Tunisia"]}, + {"emoji":"🇹🇴","name":"flag: Tonga","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Tonga"]}, + {"emoji":"🇹🇷","name":"flag: Türkiye","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Türkiye"]}, + {"emoji":"🇹🇹","name":"flag: Trinidad & Tobago","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Trinidad","&","Tobago"]}, + {"emoji":"🇹🇻","name":"flag: Tuvalu","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Tuvalu"]}, + {"emoji":"🇹🇼","name":"flag: Taiwan","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Taiwan"]}, + {"emoji":"🇹🇿","name":"flag: Tanzania","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Tanzania"]}, + {"emoji":"🇺🇦","name":"flag: Ukraine","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Ukraine"]}, + {"emoji":"🇺🇬","name":"flag: Uganda","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Uganda"]}, + {"emoji":"🇺🇲","name":"flag: U.S. Outlying Islands","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","U.S.","Outlying","Islands"]}, + {"emoji":"🇺🇳","name":"flag: United Nations","category":"Flags","version":"4.0","skinTone":false,"keywords":["flag:","United","Nations"]}, + {"emoji":"🇺🇸","name":"flag: United States","category":"Flags","version":"0.6","skinTone":false,"keywords":["flag:","United","States"]}, + {"emoji":"🇺🇾","name":"flag: Uruguay","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Uruguay"]}, + {"emoji":"🇺🇿","name":"flag: Uzbekistan","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Uzbekistan"]}, + {"emoji":"🇻🇦","name":"flag: Vatican City","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Vatican","City"]}, + {"emoji":"🇻🇨","name":"flag: St. Vincent & Grenadines","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","St.","Vincent","&","Grenadines"]}, + {"emoji":"🇻🇪","name":"flag: Venezuela","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Venezuela"]}, + {"emoji":"🇻🇬","name":"flag: British Virgin Islands","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","British","Virgin","Islands"]}, + {"emoji":"🇻🇮","name":"flag: U.S. Virgin Islands","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","U.S.","Virgin","Islands"]}, + {"emoji":"🇻🇳","name":"flag: Vietnam","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Vietnam"]}, + {"emoji":"🇻🇺","name":"flag: Vanuatu","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Vanuatu"]}, + {"emoji":"🇼🇫","name":"flag: Wallis & Futuna","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Wallis","&","Futuna"]}, + {"emoji":"🇼🇸","name":"flag: Samoa","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Samoa"]}, + {"emoji":"🇽🇰","name":"flag: Kosovo","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Kosovo"]}, + {"emoji":"🇾🇪","name":"flag: Yemen","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Yemen"]}, + {"emoji":"🇾🇹","name":"flag: Mayotte","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Mayotte"]}, + {"emoji":"🇿🇦","name":"flag: South Africa","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","South","Africa"]}, + {"emoji":"🇿🇲","name":"flag: Zambia","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Zambia"]}, + {"emoji":"🇿🇼","name":"flag: Zimbabwe","category":"Flags","version":"2.0","skinTone":false,"keywords":["flag:","Zimbabwe"]}, + {"emoji":"🏴󠁧󠁢󠁥󠁮󠁧󠁿","name":"flag: England","category":"Flags","version":"5.0","skinTone":false,"keywords":["flag:","England"]}, + {"emoji":"🏴󠁧󠁢󠁳󠁣󠁴󠁿","name":"flag: Scotland","category":"Flags","version":"5.0","skinTone":false,"keywords":["flag:","Scotland"]}, + {"emoji":"🏴󠁧󠁢󠁷󠁬󠁳󠁿","name":"flag: Wales","category":"Flags","version":"5.0","skinTone":false,"keywords":["flag:","Wales"]} + ] +} diff --git a/libs/emojis/src/test/kotlin/com/getcode/libs/emojis/reactions/EmojiCatalogLoaderTest.kt b/libs/emojis/src/test/kotlin/com/getcode/libs/emojis/reactions/EmojiCatalogLoaderTest.kt new file mode 100644 index 0000000000..66a133d612 --- /dev/null +++ b/libs/emojis/src/test/kotlin/com/getcode/libs/emojis/reactions/EmojiCatalogLoaderTest.kt @@ -0,0 +1,52 @@ +package com.getcode.libs.emojis.reactions + +import kotlinx.coroutines.runBlocking +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class EmojiCatalogLoaderTest { + + @Test + fun `loads the bundled catalog and preserves category order`() = runBlocking { + val loader = EmojiCatalogLoader() + val catalog = loader.load() + + assertTrue(catalog.categories.isNotEmpty(), "catalog has no categories") + assertTrue(catalog.entries.isNotEmpty(), "catalog has no entries") + assertEquals( + listOf( + "Smileys & People", + "Animals & Nature", + "Food & Drink", + "Travel & Places", + "Activities", + "Objects", + "Symbols", + "Flags", + ), + catalog.categories, + ) + + // Every entry's category is one the file declared, in the same order it declared them. + val declared = catalog.categories.toSet() + assertTrue(catalog.entries.all { it.category in declared }, "an entry's category is not in the declared list") + + assertEquals(catalog.categories.first(), "Smileys & People") + assertTrue(catalog.firstCategoryEntries.isNotEmpty(), "first category has no entries") + assertTrue(catalog.firstCategoryEntries.all { it.category == catalog.categories.first() }) + } + + @Test + fun `caches after the first load`() = runBlocking { + val loader = EmojiCatalogLoader() + val first = loader.load() + val second = loader.load() + assertTrue(first === second, "second load did not return the cached instance") + } +} diff --git a/libs/emojis/src/test/kotlin/com/getcode/libs/emojis/reactions/RecentReactionsVectorTest.kt b/libs/emojis/src/test/kotlin/com/getcode/libs/emojis/reactions/RecentReactionsVectorTest.kt new file mode 100644 index 0000000000..dcfcb44026 --- /dev/null +++ b/libs/emojis/src/test/kotlin/com/getcode/libs/emojis/reactions/RecentReactionsVectorTest.kt @@ -0,0 +1,96 @@ +package com.getcode.libs.emojis.reactions + +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * `test-vectors/reactions.json`. The canonical copy lives in the orchestrator repo (#17); this one + * is synced verbatim. A failure here is either a real regression or a cross-platform decision that + * has to be made in the canonical fixture and re-synced to both platforms — never a local edit. + * + * Covers `defaults`, `recents` and `drawability`; `merge`, `order` and `strip` are covered by + * `apps:flipcash:shared:chat`'s `ReactionVectorsTest`, next to where `ReactionState` lives. + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class RecentReactionsVectorTest { + + @Test + fun `defaults match the reactions fixture`() { + val expected = section("defaults") + assertTrue(expected.length() > 0, "reactions.json loaded no defaults") + val expectedList = (0 until expected.length()).map { expected.getString(it) } + assertEquals(expectedList, RecentReactions.DEFAULTS, "RecentReactions.DEFAULTS") + } + + @Test + fun `recents vectors match the reactions fixture`() { + val vectors = section("recents") + assertTrue(vectors.length() > 0, "reactions.json loaded no recents vectors") + + for (i in 0 until vectors.length()) { + val vector = vectors.getJSONObject(i) + val name = vector.getString("name") + val note = vector.getString("note") + + val usesJson = vector.getJSONArray("uses") + var stats: Map = emptyMap() + for (k in 0 until usesJson.length()) { + val use = usesJson.getJSONObject(k) + stats = RecentReactions.record( + emoji = use.getString("emoji"), + at = Instant.fromEpochSeconds(use.getLong("usedAt")), + stats = stats, + ) + } + + val undrawableJson = vector.getJSONArray("undrawable") + val undrawable = (0 until undrawableJson.length()).map { undrawableJson.getString(it) }.toSet() + + val limit = vector.getInt("limit") + + val expectedJson = vector.getJSONArray("expect") + val expected = (0 until expectedJson.length()).map { expectedJson.getString(it) } + + val actual = RecentReactions.rank(stats, undrawable, limit) + assertEquals(expected, actual, "vector `$name`: $note") + } + } + + /** + * [EmojiDrawability] delegates to [android.graphics.Paint.hasGlyph], which asks the device's + * real font fallback and so cannot be pinned to a fixed answer in a JVM unit test the way the + * pure-logic sections above can. This only checks that the predicate runs and returns a + * boolean for each vector's emoji, naming the vector so a crash points at the offending + * sequence; the actual drawability answer is verified on-device (see the module's + * instrumented/UI-level coverage; chunk B wires the strip and picker up to a real predicate). + */ + @Test + fun `drawability vectors run without crashing`() { + val vectors = section("drawability") + assertTrue(vectors.length() > 0, "reactions.json loaded no drawability vectors") + + for (i in 0 until vectors.length()) { + val vector = vectors.getJSONObject(i) + val emoji = vector.getString("emoji") + val note = vector.optString("note", "") + // Must not throw for any vector's emoji; the boolean result is real-device behavior. + EmojiDrawability.isDrawable(emoji) + assertTrue(true, "vector `$emoji`: $note") + } + } + + private fun section(name: String): JSONArray { + val json = javaClass.classLoader!! + .getResourceAsStream("reactions.json")!! + .bufferedReader().use { it.readText() } + return JSONObject(json).getJSONArray(name) + } +} From bf86c42fdf99a42b0484ca78f4dd75f2730c6ae2 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 15:36:11 -0400 Subject: [PATCH 04/58] feat(chat): page reactors and batch-fetch reaction summaries by id getReactors now returns the server paging token and hasMore, threaded through the controller/repo/service/api with a pageSize param, so the reactors sheet can page past the first 100. Adds getReactionSummariesByIds, chunked at 100 ids per request via the MessageIdBatch oneof, for the reaction refresh-on-paging flow. --- .../controllers/ChatMessagingController.kt | 30 ++++++- .../internal/network/api/ChatMessagingApi.kt | 22 ++++++ .../network/services/ChatMessagingService.kt | 28 +++++++ .../InternalChatMessagingRepository.kt | 9 ++- .../repository/ChatMessagingRepository.kt | 13 +++ .../ChatMessagingControllerTest.kt | 79 +++++++++++++++++++ 6 files changed, 179 insertions(+), 2 deletions(-) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatMessagingController.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatMessagingController.kt index 98d3105e01..8813c0d939 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatMessagingController.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatMessagingController.kt @@ -109,6 +109,7 @@ class ChatMessagingController @Inject constructor( return repository.removeReaction(owner, chatId, messageId, emoji) } + /** [queryOptions]`.limit` (page size) is clamped at [REACTORS_PAGE_SIZE_LIMIT], the server cap. */ suspend fun getReactors( chatId: ChatId, messageId: Long, @@ -116,7 +117,8 @@ class ChatMessagingController @Inject constructor( queryOptions: QueryOptions = QueryOptions(), ): Result { val owner = runCatching { requireOwner() }.getOrElse { return Result.failure(it) } - return repository.getReactors(owner, chatId, messageId, emoji, queryOptions) + val clamped = queryOptions.copy(limit = queryOptions.limit.coerceAtMost(REACTORS_PAGE_SIZE_LIMIT)) + return repository.getReactors(owner, chatId, messageId, emoji, clamped) } suspend fun getReactionSummary( @@ -135,6 +137,32 @@ class ChatMessagingController @Inject constructor( return repository.getReactionSummaries(owner, chatId, queryOptions) } + /** + * Fetches reaction summaries for [messageIds], chunking into batches of at most + * [MESSAGE_ID_BATCH_LIMIT] (the server's `MessageIdBatch` cap) and merging the results. + * Fails fast on the first chunk that fails; earlier chunks already fetched are discarded + * with it since a partial summary set isn't safe to seed reaction state from. + */ + suspend fun getReactionSummariesByIds( + chatId: ChatId, + messageIds: List, + ): Result> { + if (messageIds.isEmpty()) return Result.success(emptyList()) + val owner = runCatching { requireOwner() }.getOrElse { return Result.failure(it) } + val merged = mutableListOf() + for (chunk in messageIds.chunked(MESSAGE_ID_BATCH_LIMIT)) { + val result = repository.getReactionSummariesByIds(owner, chatId, chunk) + val summaries = result.getOrElse { return Result.failure(it) } + merged += summaries + } + return Result.success(merged) + } + + companion object { + private const val MESSAGE_ID_BATCH_LIMIT = 100 + private const val REACTORS_PAGE_SIZE_LIMIT = 100 + } + suspend fun advancePointer( chatId: ChatId, pointerType: PointerType, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatMessagingApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatMessagingApi.kt index 70222b90b9..3563dfd296 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatMessagingApi.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatMessagingApi.kt @@ -323,4 +323,26 @@ internal class ChatMessagingApi @Inject constructor( api.getReactionSummaries(request) } } + + /** [messageIds] must be non-empty and at most 100 — the `MessageIdBatch` proto cap. */ + suspend fun getReactionSummariesByIds( + owner: KeyPair, + chatId: ChatId, + messageIds: List, + ): RpcMessagingService.GetReactionSummariesResponse { + val request = RpcMessagingService.GetReactionSummariesRequest.newBuilder() + .setChatId(chatId.asChatId()) + .setMessageIds( + MessagingModel.MessageIdBatch.newBuilder() + .addAllMessageIds(messageIds.map { MessagingModel.MessageId.newBuilder().setValue(it).build() }) + ) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.getReactionSummaries(request) + } + } } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatMessagingService.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatMessagingService.kt index 0b07b0459d..dd8a5a5758 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatMessagingService.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatMessagingService.kt @@ -18,7 +18,9 @@ import com.flipcash.services.models.SendMessageError import com.flipcash.services.models.GetMessageError import com.flipcash.services.models.GetMessagesError import com.flipcash.services.models.NotifyIsTypingError +import com.flipcash.services.models.PagingToken import com.flipcash.services.models.QueryOptions +import com.flipcash.services.internal.network.extensions.toPagingToken import com.flipcash.services.models.RemoveReactionError import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ClientMessageId @@ -325,6 +327,7 @@ internal class ChatMessagingService @Inject constructor( GetReactorsResult( reactors = response.reactorsList.map { it.toReactor() }, hasMore = response.hasMore, + pagingToken = if (response.hasPagingToken()) response.pagingToken.toPagingToken() else null, ) ) RpcMessagingService.GetReactorsResponse.Result.DENIED -> Result.failure(GetReactorsError.Denied()) @@ -384,6 +387,30 @@ internal class ChatMessagingService @Inject constructor( } ) } + + /** One `MessageIdBatch` call; [messageIds] must be non-empty and at most 100 (server cap). */ + suspend fun getReactionSummariesByIds( + owner: KeyPair, + chatId: ChatId, + messageIds: List, + ): Result> { + return runCatching { + api.getReactionSummariesByIds(owner, chatId, messageIds) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcMessagingService.GetReactionSummariesResponse.Result.OK -> + Result.success(response.summariesList.map { it.toReactionSummary() }) + RpcMessagingService.GetReactionSummariesResponse.Result.DENIED -> Result.failure(GetReactionSummariesError.Denied()) + RpcMessagingService.GetReactionSummariesResponse.Result.UNRECOGNIZED -> Result.failure(GetReactionSummariesError.Unrecognized()) + else -> Result.failure(GetReactionSummariesError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { GetReactionSummariesError.Other(cause = it) }) + } + ) + } } data class GetDeltaResult( @@ -395,4 +422,5 @@ data class GetDeltaResult( data class GetReactorsResult( val reactors: List, val hasMore: Boolean, + val pagingToken: PagingToken? = null, ) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatMessagingRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatMessagingRepository.kt index 5ba26a2157..1e8ae54c97 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatMessagingRepository.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatMessagingRepository.kt @@ -122,7 +122,7 @@ internal class InternalChatMessagingRepository( queryOptions: QueryOptions, ): Result = service.getReactors(owner, chatId, messageId, emoji, queryOptions) .onFailure { ErrorUtils.handleError(it) } - .map { ReactorsPage(reactors = it.reactors, hasMore = it.hasMore) } + .map { ReactorsPage(reactors = it.reactors, hasMore = it.hasMore, nextToken = it.pagingToken) } override suspend fun getReactionSummary( owner: KeyPair, @@ -138,6 +138,13 @@ internal class InternalChatMessagingRepository( ): Result> = service.getReactionSummaries(owner, chatId, queryOptions) .onFailure { ErrorUtils.handleError(it) } + override suspend fun getReactionSummariesByIds( + owner: KeyPair, + chatId: ChatId, + messageIds: List, + ): Result> = service.getReactionSummariesByIds(owner, chatId, messageIds) + .onFailure { ErrorUtils.handleError(it) } + override suspend fun advancePointer( owner: KeyPair, chatId: ChatId, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatMessagingRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatMessagingRepository.kt index 3b472b5f8f..56a5086347 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatMessagingRepository.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatMessagingRepository.kt @@ -100,6 +100,17 @@ interface ChatMessagingRepository { queryOptions: QueryOptions, ): Result> + /** + * Fetches reaction summaries for specific messages. [messageIds] must be non-empty and at + * most 100 long — the server rejects a larger batch; callers with more ids chunk and merge + * (see [com.flipcash.services.controllers.ChatMessagingController.getReactionSummariesByIds]). + */ + suspend fun getReactionSummariesByIds( + owner: KeyPair, + chatId: ChatId, + messageIds: List, + ): Result> + suspend fun advancePointer( owner: KeyPair, chatId: ChatId, @@ -123,4 +134,6 @@ data class DeltaUpdate( data class ReactorsPage( val reactors: List, val hasMore: Boolean, + /** Pass as `queryOptions.token` on the next [ChatMessagingRepository.getReactors] call. Set only when [hasMore]. */ + val nextToken: com.flipcash.services.models.PagingToken? = null, ) diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatMessagingControllerTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatMessagingControllerTest.kt index 957b0f097a..3ddd48f844 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatMessagingControllerTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatMessagingControllerTest.kt @@ -454,6 +454,77 @@ class ChatMessagingControllerTest { // endregion + // region getReactionSummariesByIds + + @Test + fun `getReactionSummariesByIds fails when no account cluster`() = runTest { + every { userManager.accountCluster } returns null + val result = controller.getReactionSummariesByIds(testChatId, listOf(1L)) + assertTrue(result.isFailure) + assertIs(result.exceptionOrNull()) + } + + @Test + fun `getReactionSummariesByIds succeeds without touching the repository when empty`() = runTest { + stubOwner() + + val result = controller.getReactionSummariesByIds(testChatId, emptyList()) + + assertEquals(emptyList(), result.getOrThrow()) + assertEquals(emptyList>(), repository.getReactionSummariesByIdsCalls) + } + + @Test + fun `getReactionSummariesByIds sends ids under 100 as a single chunk`() = runTest { + stubOwner() + val ids = (1L..5L).toList() + repository.getReactionSummariesByIdsResult = { chunk -> Result.success(chunk.map { ReactionSummary(messageId = it, reactions = emptyList()) }) } + + val result = controller.getReactionSummariesByIds(testChatId, ids) + + assertEquals(listOf(ids), repository.getReactionSummariesByIdsCalls) + assertEquals(5, result.getOrThrow().size) + } + + @Test + fun `getReactionSummariesByIds chunks over 100 ids and merges results`() = runTest { + stubOwner() + val ids = (1L..150L).toList() + repository.getReactionSummariesByIdsResult = { chunk -> Result.success(chunk.map { ReactionSummary(messageId = it, reactions = emptyList()) }) } + + val result = controller.getReactionSummariesByIds(testChatId, ids) + + assertEquals(2, repository.getReactionSummariesByIdsCalls.size) + assertEquals(100, repository.getReactionSummariesByIdsCalls[0].size) + assertEquals(50, repository.getReactionSummariesByIdsCalls[1].size) + assertEquals(150, result.getOrThrow().size) + assertEquals(ids, result.getOrThrow().map { it.messageId }) + } + + @Test + fun `getReactionSummariesByIds fails fast on the first failing chunk`() = runTest { + stubOwner() + val ids = (1L..150L).toList() + val cause = RuntimeException("boom") + var calls = 0 + repository.getReactionSummariesByIdsResult = { chunk -> + calls++ + if (calls == 1) { + Result.success(chunk.map { ReactionSummary(messageId = it, reactions = emptyList()) }) + } else { + Result.failure(cause) + } + } + + val result = controller.getReactionSummariesByIds(testChatId, ids) + + assertTrue(result.isFailure) + assertSame(cause, result.exceptionOrNull()) + assertEquals(2, repository.getReactionSummariesByIdsCalls.size) + } + + // endregion + // region getDelta @Test @@ -491,6 +562,8 @@ private class FakeChatMessagingRepository : ChatMessagingRepository { var getReactorsResult: Result = Result.failure(RuntimeException("not configured")) var getReactionSummaryResult: Result = Result.failure(RuntimeException("not configured")) var getReactionSummariesResult: Result> = Result.failure(RuntimeException("not configured")) + var getReactionSummariesByIdsResult: (List) -> Result> = { Result.failure(RuntimeException("not configured")) } + val getReactionSummariesByIdsCalls: MutableList> = mutableListOf() var advancePointerResult: Result = Result.failure(RuntimeException("not configured")) var notifyIsTypingResult: Result = Result.failure(RuntimeException("not configured")) @@ -566,6 +639,12 @@ private class FakeChatMessagingRepository : ChatMessagingRepository { return getReactionSummariesResult } + override suspend fun getReactionSummariesByIds(owner: Ed25519.KeyPair, chatId: ChatId, messageIds: List): Result> { + lastChatId = chatId + getReactionSummariesByIdsCalls += messageIds + return getReactionSummariesByIdsResult(messageIds) + } + override suspend fun advancePointer(owner: Ed25519.KeyPair, chatId: ChatId, pointerType: PointerType, messageId: Long): Result { lastChatId = chatId; lastPointerType = pointerType; lastMessageId = messageId return advancePointerResult From be071d4d6ea5b229d46a36ba5a1d61add154f777 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 15:45:04 -0400 Subject: [PATCH 05/58] feat(chat): merge reactions by version on message upsert --- .../sources/mapper/chat/ChatEntityMapper.kt | 42 ++++++++++-- .../mapper/chat/ChatEntityMapperTest.kt | 64 +++++++++++++++++++ 2 files changed, 100 insertions(+), 6 deletions(-) diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt index 32526442df..fd8ef7f47d 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt @@ -57,6 +57,8 @@ import kotlin.time.Instant @Singleton class ChatEntityMapper @Inject constructor() { + private val reactionJson = kotlinx.serialization.json.Json { ignoreUnknownKeys = true } + // region ChatMetadata /** @@ -171,9 +173,7 @@ class ChatEntityMapper @Inject constructor() { unreadSeq = message.unreadSeq, eventSequence = message.eventSequence, lastEditedTsEpochMs = message.lastEditedTs?.toEpochMilliseconds(), - reactionsJson = message.reactions?.toSerialized()?.let { - kotlinx.serialization.json.Json.encodeToString(it) - }, + reactionsJson = encodeReactions(message.reactions), isDeleted = message.content.any { it is MessageContent.Deleted }, ) } @@ -187,9 +187,7 @@ class ChatEntityMapper @Inject constructor() { unreadSeq = entity.unreadSeq, eventSequence = entity.eventSequence, lastEditedTs = entity.lastEditedTsEpochMs?.let { Instant.fromEpochMilliseconds(it) }, - reactions = entity.reactionsJson?.let { - kotlinx.serialization.json.Json.decodeFromString(it).toDomain() - }, + reactions = decodeReactions(entity.reactionsJson), deliveryStatus = when (entity.status) { MessageStatus.SENDING -> DeliveryStatus.SENDING MessageStatus.SENT -> DeliveryStatus.SENT @@ -218,6 +216,38 @@ class ChatEntityMapper @Inject constructor() { ) } + /** Encodes a [ReactionSummary] for [ChatMessageEntity.reactionsJson], or `null` for none. */ + fun encodeReactions(summary: ReactionSummary?): String? = + summary?.toSerialized()?.let { reactionJson.encodeToString(it) } + + /** Decodes [ChatMessageEntity.reactionsJson] back to a [ReactionSummary], or `null` for none. */ + fun decodeReactions(json: String?): ReactionSummary? = + json?.let { reactionJson.decodeFromString(it).toDomain() } + + /** + * Merges [incoming] onto [stored], per emoji, keeping whichever entry carries the higher + * `version` — mirrors [com.flipcash.shared.chat.reactions.ReactionState]'s accept-by-version + * rule so a message upsert can't clobber a newer confirmed reaction with a stale one. An emoji + * [stored] holds that [incoming] omits is kept: [incoming] is a live payload attached to one + * message write, not an authoritative "this and only this" summary. + */ + fun mergeReactions(stored: ReactionSummary?, incoming: ReactionSummary): ReactionSummary { + if (stored == null) return incoming + val storedByEmoji = stored.reactions.associateBy { it.emoji } + val incomingByEmoji = incoming.reactions.associateBy { it.emoji } + val merged = (storedByEmoji.keys + incomingByEmoji.keys).mapNotNull { emoji -> + val storedEntry = storedByEmoji[emoji] + val incomingEntry = incomingByEmoji[emoji] + when { + incomingEntry == null -> storedEntry + storedEntry == null -> incomingEntry + incomingEntry.version >= storedEntry.version -> incomingEntry + else -> storedEntry + } + } + return ReactionSummary(messageId = incoming.messageId, reactions = merged) + } + // endregion // region ChatMember diff --git a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt index 2fe69492de..28d2e29fae 100644 --- a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt +++ b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt @@ -301,6 +301,70 @@ class ChatEntityMapperTest { assertEquals(true, restored.viewerState?.permissions?.canEdit) } + // region Reactions + + @Test + fun `null reactions round-trip through encode and decode as null`() { + assertEquals(null, mapper.encodeReactions(null)) + assertEquals(null, mapper.decodeReactions(null)) + } + + @Test + fun `a reaction summary round-trips through encode and decode`() { + val summary = reactionSummary(emoji("👍") to 3L) + + val decoded = mapper.decodeReactions(mapper.encodeReactions(summary)) + + assertEquals(summary, decoded) + } + + @Test + fun `merge keeps the stored entry when incoming omits its emoji`() { + val stored = reactionSummary(emoji("👍") to 1L, emoji("❤️") to 1L) + val incoming = reactionSummary(emoji("👍") to 2L) + + val merged = mapper.mergeReactions(stored, incoming) + + assertEquals(setOf("👍", "❤️"), merged.reactions.map { it.emoji.value }.toSet()) + } + + @Test + fun `merge keeps the higher-version entry per emoji`() { + val stored = reactionSummary(emoji("👍") to 5L) + val incoming = reactionSummary(emoji("👍") to 2L) + + val merged = mapper.mergeReactions(stored, incoming) + + assertEquals(5L, merged.reactions.single().count) + } + + @Test + fun `merge onto null stored state returns the incoming summary unchanged`() { + val incoming = reactionSummary(emoji("👍") to 1L) + + assertEquals(incoming, mapper.mergeReactions(null, incoming)) + } + + private fun emoji(value: String) = com.flipcash.services.models.chat.Emoji(value) + + /** Builds a [ReactionSummary] with one [EmojiReaction] per (emoji, version) pair, version doubling as count for easy assertions. */ + private fun reactionSummary( + vararg entries: Pair, + ) = com.flipcash.services.models.chat.ReactionSummary( + messageId = 42L, + reactions = entries.map { (emoji, version) -> + com.flipcash.services.models.chat.EmojiReaction( + emoji = emoji, + count = version, + selfReactor = null, + sampleReactors = emptyList(), + version = version, + ) + }, + ) + + // endregion + private companion object { const val CHAT_HEX = "aabbccdd" } From 3183abd46f629b07e700fc9f473c936c1ea3faf8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 15:49:14 -0400 Subject: [PATCH 06/58] feat(chat): add reactions-only DAO writes and a JSON-level merge --- .../converters/ChatTypeConverters.kt | 27 ++++++ .../app/persistence/dao/ChatMessageDao.kt | 42 ++++++++- .../app/persistence/dao/ChatMessageDaoTest.kt | 91 +++++++++++++++++++ 3 files changed, 159 insertions(+), 1 deletion(-) diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt index cf1b4ffc9a..141d81e29b 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt @@ -13,6 +13,33 @@ private val json = Json { encodeDefaults = true } +/** + * Merges a message's stored `reactions_json` with an incoming payload, per emoji, keeping + * whichever side carries the higher `version` — mirrors iOS's `mergeReactions`/`applySummary`. + * `incomingJson == null` keeps [storedJson] as-is: a write that carries no reaction data (an + * ordinary content upsert) must not erase confirmed reactions already on disk. `storedJson == + * null` (nothing stored yet) takes [incomingJson] outright. + */ +internal fun mergeReactionsJson(storedJson: String?, incomingJson: String?): String? { + if (incomingJson == null) return storedJson + if (storedJson == null) return incomingJson + val stored = json.decodeFromString(storedJson) + val incoming = json.decodeFromString(incomingJson) + val storedByEmoji = stored.reactions.associateBy { it.emoji } + val incomingByEmoji = incoming.reactions.associateBy { it.emoji } + val merged = (storedByEmoji.keys + incomingByEmoji.keys).mapNotNull { emoji -> + val storedEntry = storedByEmoji[emoji] + val incomingEntry = incomingByEmoji[emoji] + when { + incomingEntry == null -> storedEntry + storedEntry == null -> incomingEntry + incomingEntry.version >= storedEntry.version -> incomingEntry + else -> storedEntry + } + } + return json.encodeToString(incoming.copy(reactions = merged)) +} + class ChatTypeConverters { // region MessageContent diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt index a4ae030d26..76f97c05a9 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt @@ -6,6 +6,7 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction +import com.flipcash.app.persistence.converters.mergeReactionsJson import com.flipcash.app.persistence.entities.ChatMessageEntity import com.flipcash.app.persistence.entities.MessageStatus import kotlinx.coroutines.flow.Flow @@ -140,6 +141,36 @@ interface ChatMessageDao { @Query("SELECT event_sequence FROM chat_messages WHERE chat_id_hex = :chatIdHex AND message_id = :messageId") suspend fun getEventSequence(chatIdHex: String, messageId: Long): Long? + @Query("SELECT reactions_json FROM chat_messages WHERE chat_id_hex = :chatIdHex AND message_id = :messageId") + suspend fun getReactionsJson(chatIdHex: String, messageId: Long): String? + + @Query("SELECT EXISTS(SELECT 1 FROM chat_messages WHERE chat_id_hex = :chatIdHex AND message_id = :messageId)") + suspend fun exists(chatIdHex: String, messageId: Long): Boolean + + /** + * Reactions-only write: touches `reactions_json` alone, leaving every other column — content, + * status, event_sequence — untouched. A row must already exist; a stream reaction event for a + * message not yet stored is dropped by the caller (mirrors iOS's `updateReactions`, which skips + * unstored messages rather than writing a bare row). + */ + @Query("UPDATE chat_messages SET reactions_json = :reactionsJson WHERE chat_id_hex = :chatIdHex AND message_id = :messageId") + suspend fun updateReactionsJson(chatIdHex: String, messageId: Long, reactionsJson: String?) + + /** + * Merges [incomingJson] onto the message's stored `reactions_json`, per emoji, keeping + * whichever side has the higher `version` (see [mergeReactionsJson]), then writes the result + * back. Does nothing if the message isn't stored. This is the reactions-only counterpart of + * the merge [upsert] already does for a full-row write — used when a reaction summary arrives + * on its own (a stream `ReactionUpdate`, or a batch summary refresh) rather than attached to a + * message. + */ + @Transaction + suspend fun mergeReactionsJson(chatIdHex: String, messageId: Long, incomingJson: String) { + if (!exists(chatIdHex, messageId)) return + val stored = getReactionsJson(chatIdHex, messageId) + updateReactionsJson(chatIdHex, messageId, mergeReactionsJson(stored, incomingJson)) + } + @Query("SELECT unread_seq FROM chat_messages WHERE chat_id_hex = :chatIdHex AND message_id = :messageId") suspend fun getUnreadSeq(chatIdHex: String, messageId: Long): Long? @@ -161,9 +192,18 @@ interface ChatMessageDao { } val existingPendingId = getPendingClientId(entity.chatIdHex, entity.messageId) - val merged = if (existingPendingId != null && entity.pendingClientIdHex == null) { + var merged = if (existingPendingId != null && entity.pendingClientIdHex == null) { entity.copy(pendingClientIdHex = existingPendingId) } else entity + + // Reactions: never let this write clobber a newer confirmed reaction with an older or + // absent one. mergeReactionsJson keeps the stored side when entity carries none, and + // per-emoji picks whichever side's `version` is higher otherwise. + val storedReactionsJson = getReactionsJson(entity.chatIdHex, entity.messageId) + merged = merged.copy( + reactionsJson = mergeReactionsJson(storedReactionsJson, entity.reactionsJson), + ) + insert(merged) } diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt index 10eb6c0373..545e695c3c 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt @@ -5,10 +5,13 @@ import androidx.paging.PagingSource import androidx.room.Room import androidx.test.core.app.ApplicationProvider import com.flipcash.app.persistence.FlipcashDatabase +import com.flipcash.app.persistence.converters.EmojiReactionSerialized import com.flipcash.app.persistence.converters.MessageContentSerialized +import com.flipcash.app.persistence.converters.ReactionSummarySerialized import com.flipcash.app.persistence.entities.ChatMessageEntity import com.flipcash.app.persistence.entities.MessageStatus import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json import org.junit.After import org.junit.Before import org.junit.Test @@ -25,6 +28,8 @@ import kotlin.test.assertNull @RunWith(RobolectricTestRunner::class) class ChatMessageDaoTest { + private val reactionJsonCodec = Json { ignoreUnknownKeys = true } + private lateinit var db: FlipcashDatabase private lateinit var dao: ChatMessageDao @@ -365,6 +370,92 @@ class ChatMessageDaoTest { assertEquals(MessageStatus.SENT, dao.getMessage(CHAT_HEX, 1)?.status) } + // region Reactions + + private fun reactionsJson(vararg entries: Pair) = reactionJsonCodec.encodeToString( + ReactionSummarySerialized( + messageId = 1, + reactions = entries.map { (emoji, version) -> + EmojiReactionSerialized(emoji = emoji, count = version, sampleReactors = emptyList(), version = version) + }, + ) + ) + + private fun versionsByEmoji(json: String?) = json + ?.let { reactionJsonCodec.decodeFromString(it) } + ?.reactions + ?.associate { it.emoji to it.version } + .orEmpty() + + /** + * A plain content upsert (edit, delivery-status refresh, server echo) carries no reaction data + * of its own. It must not wipe out a reaction a prior `ReactionUpdate` already confirmed. + */ + @Test + fun `upsert without reactions keeps the reactions already stored`() = runTest { + dao.upsert(text(1, "hi")) + dao.mergeReactionsJson(CHAT_HEX, 1, reactionsJson("👍" to 1)) + + dao.upsert(text(1, "hi edited")) + + assertEquals(mapOf("👍" to 1L), versionsByEmoji(dao.getReactionsJson(CHAT_HEX, 1))) + } + + /** An upsert carrying a newer reaction summary wins over the older one on disk. */ + @Test + fun `upsert with a newer reaction summary replaces the stale one`() = runTest { + dao.upsert(text(1, "hi")) + dao.mergeReactionsJson(CHAT_HEX, 1, reactionsJson("👍" to 1)) + + dao.upsert(text(1, "hi").copy(reactionsJson = reactionsJson("👍" to 2))) + + assertEquals(mapOf("👍" to 2L), versionsByEmoji(dao.getReactionsJson(CHAT_HEX, 1))) + } + + /** An upsert carrying a stale reaction summary does not regress the newer one on disk. */ + @Test + fun `upsert with a stale reaction summary is ignored`() = runTest { + dao.upsert(text(1, "hi")) + dao.mergeReactionsJson(CHAT_HEX, 1, reactionsJson("👍" to 5)) + + dao.upsert(text(1, "hi").copy(reactionsJson = reactionsJson("👍" to 2))) + + assertEquals(mapOf("👍" to 5L), versionsByEmoji(dao.getReactionsJson(CHAT_HEX, 1))) + } + + @Test + fun `updateReactionsJson writes reactions without touching other columns`() = runTest { + dao.upsert(text(1, "hi")) + + dao.updateReactionsJson(CHAT_HEX, 1, reactionsJson("❤️" to 1)) + + val stored = dao.getMessage(CHAT_HEX, 1)!! + assertEquals("hi", (stored.contentJson?.first() as MessageContentSerialized.Text).text) + assertEquals(mapOf("❤️" to 1L), versionsByEmoji(stored.reactionsJson)) + } + + @Test + fun `mergeReactionsJson does nothing for a message that isn't stored`() = runTest { + dao.mergeReactionsJson(CHAT_HEX, 99, reactionsJson("👍" to 1)) + + assertNull(dao.getReactionsJson(CHAT_HEX, 99)) + } + + @Test + fun `mergeReactionsJson keeps a stored emoji the incoming payload omits`() = runTest { + dao.upsert(text(1, "hi")) + dao.mergeReactionsJson(CHAT_HEX, 1, reactionsJson("👍" to 1, "❤️" to 1)) + + dao.mergeReactionsJson(CHAT_HEX, 1, reactionsJson("👍" to 2)) + + assertEquals( + mapOf("👍" to 2L, "❤️" to 1L), + versionsByEmoji(dao.getReactionsJson(CHAT_HEX, 1)), + ) + } + + // endregion + private companion object { const val CHAT_HEX = "aabb" const val OTHER_HEX = "ccdd" From 0e7d648e91b8c601f0438955733bcd63b95c4eb4 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 15:53:39 -0400 Subject: [PATCH 07/58] feat(chat): route stream reaction updates through the reducer and persist them --- .../internal/delegates/EventStreamDelegate.kt | 115 +++++++++--------- .../shared/chat/ChatCoordinatorEventsTest.kt | 102 ++++++++++++++++ .../sources/ChatMessageDataSource.kt | 12 ++ 3 files changed, 172 insertions(+), 57 deletions(-) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt index 687eca2c64..0fea989412 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt @@ -11,6 +11,7 @@ import com.flipcash.services.controllers.EventStreamingController import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatUpdate +import com.flipcash.services.models.chat.Emoji import com.flipcash.services.models.chat.EmojiReaction import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.MetadataUpdate @@ -18,6 +19,7 @@ import com.flipcash.services.models.chat.ReactionSummary import com.flipcash.services.models.chat.ReactionUpdate import com.flipcash.services.models.chat.Reactor import com.flipcash.services.models.chat.RosterChange +import com.flipcash.shared.chat.reactions.ReactionState import com.flipcash.services.models.chat.TypingNotification import com.flipcash.services.models.chat.TypingState import com.flipcash.services.models.GetDeltaError @@ -27,6 +29,7 @@ import com.flipcash.shared.chat.EventStreamOperations import com.flipcash.shared.chat.internal.ChatStateHolder import com.flipcash.services.user.UserManager import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.model.core.ID import com.getcode.utils.TraceType import com.getcode.utils.trace import kotlinx.coroutines.CoroutineScope @@ -115,6 +118,17 @@ class EventStreamDelegate @Inject constructor( private var eventStreamCollectJob: Job? = null private var heartbeatJob: Job? = null + /** + * One [ReactionState] per message touched by a stream update, keyed by chat then message id. + * Only [ReactionState.applyUpdate] is called here — server-confirmed state only. A coordinator + * layer above this owns any pending taps, and merges its own [ReactionState] against what this + * delegate persists rather than sharing an instance with it. + */ + private val reactionStates: MutableMap> = mutableMapOf() + + private fun reactionState(chatId: ChatId, messageId: Long): ReactionState = + reactionStates.getOrPut(chatId) { mutableMapOf() }.getOrPut(messageId) { ReactionState() } + // region EventStreamOperations override fun observeTypingIndicators(chatId: ChatId): Flow> { @@ -285,6 +299,7 @@ class EventStreamDelegate @Inject constructor( internal fun clearAll() { sequenceTracker.clearAll() + reactionStates.clear() } private fun ensureCollector(scope: CoroutineScope) { @@ -398,14 +413,31 @@ class EventStreamDelegate @Inject constructor( // --- Process reaction updates --- if (update.reactionUpdates.isNotEmpty()) { - stateHolder.update { state -> - val chatOverlays = state.reactionOverlays[chatId]?.toMutableMap() ?: mutableMapOf() - for (reactionUpdate in update.reactionUpdates) { - applyReactionUpdate(chatOverlays, reactionUpdate) - } - state.copy( - reactionOverlays = state.reactionOverlays + (chatId to chatOverlays.toMap()) + val touchedMessageIds = mutableSetOf() + for (reactionUpdate in update.reactionUpdates) { + reactionState(chatId, reactionUpdate.messageId).applyUpdate( + emoji = reactionUpdate.emoji.value, + actorIsSelf = reactionUpdate.actor == userManager.accountId, + added = reactionUpdate.action == ReactionUpdate.Action.ADDED, + count = reactionUpdate.count, + version = reactionUpdate.version, + reactedAt = reactionUpdate.reactedAt, ) + touchedMessageIds.add(reactionUpdate.messageId) + } + + val selfId = userManager.accountId + val summaries = touchedMessageIds.associateWith { messageId -> + reactionState(chatId, messageId).toReactionSummary(messageId, selfId) + } + + stateHolder.update { state -> + val chatOverlays = (state.reactionOverlays[chatId] ?: emptyMap()) + summaries + state.copy(reactionOverlays = state.reactionOverlays + (chatId to chatOverlays)) + } + + for ((messageId, summary) in summaries) { + messageDataSource.mergeReactions(chatId, messageId, summary) } } @@ -458,60 +490,29 @@ class EventStreamDelegate @Inject constructor( } } - private fun applyReactionUpdate( - overlays: MutableMap, - update: ReactionUpdate, - ) { - val existing = overlays[update.messageId] - val existingReactions = existing?.reactions?.toMutableList() ?: mutableListOf() - - // The server does not report a "self" flag on the update itself, so the client - // maintains EmojiReaction.selfReactor by comparing the update's actor to the - // signed-in user: an ADDED update from self sets it, a REMOVED update from self - // clears it, and an update from anyone else leaves the existing self state alone. - val isSelfActor = update.actor == userManager.accountId - - val idx = existingReactions.indexOfFirst { it.emoji == update.emoji } - if (idx >= 0) { - val current = existingReactions[idx] - if (update.version <= current.version) return - val selfReactor = when { - isSelfActor && update.action == ReactionUpdate.Action.ADDED -> - Reactor(userId = update.actor, reactedAt = update.reactedAt, version = current.selfReactor?.version ?: 0) - isSelfActor && update.action == ReactionUpdate.Action.REMOVED -> null - else -> current.selfReactor - } - existingReactions[idx] = EmojiReaction( - emoji = update.emoji, - count = update.count, - selfReactor = selfReactor, - sampleReactors = current.sampleReactors, - version = update.version, - ) - } else { - val selfReactor = if (isSelfActor && update.action == ReactionUpdate.Action.ADDED) { - Reactor(userId = update.actor, reactedAt = update.reactedAt) - } else { - null - } - existingReactions.add( + /** + * [ReactionState.summaryEntries] as a [ReactionSummary] — the shape [observeReactions] exposes + * and [ChatMessageDataSource.mergeReactions] persists. The reducer tracks only whether the + * viewer reacted, not the full [Reactor] the wire type wants for `selfReactor`, so that field + * is rebuilt here from [selfId]; a stream update carries no sample reactors at all (those come + * from the reactors sheet's own paged fetch), so [EmojiReaction.sampleReactors] is always empty + * for a summary built this way. + */ + private fun ReactionState.toReactionSummary(messageId: Long, selfId: ID?): ReactionSummary = + ReactionSummary( + messageId = messageId, + reactions = summaryEntries.map { entry -> EmojiReaction( - emoji = update.emoji, - count = update.count, - selfReactor = selfReactor, + emoji = Emoji(entry.emoji), + count = entry.count, + selfReactor = if (entry.selfReacted && selfId != null) { + Reactor(userId = selfId, reactedAt = entry.selfReactedAt ?: Clock.System.now(), version = entry.version) + } else null, sampleReactors = emptyList(), - version = update.version, + version = entry.version, ) - ) - } - - existingReactions.removeAll { it.count <= 0 } - - overlays[update.messageId] = ReactionSummary( - messageId = update.messageId, - reactions = existingReactions.toList(), + }, ) - } private fun scheduleGapFill(chatId: ChatId) { val scope = scope ?: return diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt index 23da7d850a..3dc26a14fc 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt @@ -418,6 +418,77 @@ class ChatCoordinatorEventsTest { } } + /** + * The reducer's tombstone: a removal-to-zero keeps the emoji's version rather than dropping + * it, so a stale ADDED that arrives after the removal (redelivered, or racing on the wire) + * cannot resurrect the pill. The old ad hoc overlay merge had no tombstone — it forgot the + * emoji's version as soon as the count hit zero — so a late stale ADDED like this one would + * have been accepted. + */ + @Test + fun `stale ADDED after a removal-to-zero is ignored`() = runTest(testDispatchers.dispatcher) { + tornDown { + triggerCollection() + + chatUpdatesChannel.send(ChatUpdate( + chatId = chatId, + reactionUpdates = listOf( + ReactionUpdate( + messageId = 1L, + emoji = Emoji("😀"), + actor = otherId, + action = ReactionUpdate.Action.ADDED, + count = 1, + version = 1, + reactedAt = Instant.fromEpochSeconds(1000), + ), + ), + )) + advanceTimeBy(500.milliseconds) + runCurrent() + + // Removal takes the count to zero at version 2. + chatUpdatesChannel.send(ChatUpdate( + chatId = chatId, + reactionUpdates = listOf( + ReactionUpdate( + messageId = 1L, + emoji = Emoji("😀"), + actor = otherId, + action = ReactionUpdate.Action.REMOVED, + count = 0, + version = 2, + reactedAt = Instant.fromEpochSeconds(2000), + ), + ), + )) + advanceTimeBy(500.milliseconds) + runCurrent() + + // A stale ADDED redelivered at version 1 must not resurrect the pill. + chatUpdatesChannel.send(ChatUpdate( + chatId = chatId, + reactionUpdates = listOf( + ReactionUpdate( + messageId = 1L, + emoji = Emoji("😀"), + actor = otherId, + action = ReactionUpdate.Action.ADDED, + count = 1, + version = 1, + reactedAt = Instant.fromEpochSeconds(1000), + ), + ), + )) + advanceTimeBy(500.milliseconds) + runCurrent() + + val reactions = coordinator.state.value.reactionOverlays[chatId]?.get(1L)?.reactions + assertNotNull(reactions) + assertTrue(reactions.isEmpty()) + } + } + @Test fun `multiple emoji reactions on same message`() = runTest(testDispatchers.dispatcher) { tornDown { @@ -455,6 +526,37 @@ class ChatCoordinatorEventsTest { } } + /** A stream reaction update is persisted, not just held in the in-memory overlay. */ + @Test + fun `reaction update is persisted to the message data source`() = runTest(testDispatchers.dispatcher) { + tornDown { + triggerCollection() + + chatUpdatesChannel.send(ChatUpdate( + chatId = chatId, + reactionUpdates = listOf( + ReactionUpdate( + messageId = 1L, + emoji = Emoji("😀"), + actor = otherId, + action = ReactionUpdate.Action.ADDED, + count = 1, + version = 1, + reactedAt = Instant.fromEpochSeconds(1000), + ), + ), + )) + advanceTimeBy(500.milliseconds) + runCurrent() + + coVerify { + messageDataSource.mergeReactions(chatId, 1L, match { summary -> + summary.reactions.singleOrNull()?.let { it.emoji.value == "😀" && it.count == 1L } == true + }) + } + } + } + // endregion // region Session lifecycle diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt index 9aed3ba0e8..3e09ddc12e 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt @@ -8,6 +8,7 @@ import com.flipcash.app.persistence.sources.mapper.chat.ChatEntityMapper import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ClientMessageId +import com.flipcash.services.models.chat.ReactionSummary import com.flipcash.app.persistence.entities.MessageStatus import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.persistence.PagingDataSource @@ -271,6 +272,17 @@ class ChatMessageDataSource @Inject constructor( return clientMessageId } + /** + * Merges [summary] onto the message's stored `reactions_json`, per emoji keeping whichever + * side has the higher `version` (see [ChatMessageDao.mergeReactionsJson]). A no-op if the + * message isn't stored yet — a reaction event for a message this device hasn't seen is + * dropped rather than written as a bare row. + */ + suspend fun mergeReactions(chatId: ChatId, messageId: Long, summary: ReactionSummary) { + val json = mapper.encodeReactions(summary) ?: return + db?.chatMessageDao()?.mergeReactionsJson(mapper.chatIdHex(chatId), messageId, json) + } + fun toChatMessage(entity: ChatMessageEntity): ChatMessage { val message = mapper.toMessage(entity) val selfId = userManager.accountId From d5b5744474d7cf06ddc5efc1047247c11bbe4df7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 15:57:52 -0400 Subject: [PATCH 08/58] =?UTF-8?q?feat(chat):=20add=20ReactionOperations=20?= =?UTF-8?q?=E2=80=94=20toggle,=20refresh,=20and=20page=20reactors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../flipcash/shared/chat/ChatCoordinator.kt | 67 +++++- .../chat/internal/RealChatCoordinator.kt | 7 +- .../internal/delegates/ReactionsDelegate.kt | 211 ++++++++++++++++++ 3 files changed, 283 insertions(+), 2 deletions(-) create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/ReactionsDelegate.kt diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt index e56f7a696d..9919fba5ff 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt @@ -15,12 +15,18 @@ import com.flipcash.services.models.chat.EditChatParameters import com.flipcash.services.models.chat.IdempotencyKey import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.MessagePointer +import com.flipcash.services.models.PagingToken import com.flipcash.services.models.chat.MuteState import com.flipcash.services.models.chat.ReactionSummary import com.flipcash.services.models.chat.StartChatParameters import com.flipcash.services.models.chat.TypingState +import com.flipcash.services.repository.ReactorsPage +import com.flipcash.shared.chat.reactions.ReactionError +import com.flipcash.shared.chat.reactions.ReactionPill +import com.flipcash.shared.chat.reactions.SelfReaction import com.getcode.opencode.model.core.ID import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow /** @@ -83,6 +89,64 @@ interface EventStreamOperations { fun observeReactions(chatId: ChatId, messageId: Long): Flow } +/** + * One message's reactions as the transcript draws them: pills already in display order, and which + * of them the viewer is shown reacting with — the pending tap, if one has not settled yet, + * otherwise the confirmed state. + */ +data class MessageReactions( + val pills: List, + val selfReactions: List, +) + +/** + * Reaction taps and lookups for a single message: toggling, refreshing confirmed state from the + * server, and paging the "who reacted" sheet. + * + * Implemented by [com.flipcash.shared.chat.internal.delegates.ReactionsDelegate]. + */ +interface ReactionOperations { + /** + * Reactive per-message reaction state for every message in [chatId] that has one — confirmed + * server state (persisted, and kept current by the event stream) merged with this device's own + * pending taps and in-flight calls, keyed by message id. + */ + fun observeChatReactions(chatId: ChatId): Flow> + + /** + * Flips the viewer's reaction to [emoji] on [messageId] and sends the resulting add or remove + * to the server, coalescing a follow-up tap made before the first call answers into one more + * call rather than a queue of them. A tap while a call for the same emoji is already in flight + * is recorded and sent once that call answers, without another call now. + * + * Optimistic: [observeChatReactions] reflects the tap immediately. A successful add records + * [emoji] to the recents ranking; a remove does not. A failure rolls the pill back and, unless + * the failure is [com.flipcash.shared.chat.reactions.ReactionFailure.MESSAGE_NOT_FOUND] or + * [com.flipcash.shared.chat.reactions.ReactionFailure.CANNOT_REACT] (both silent — the message + * or the viewer's standing in the chat has moved on since the tap), is also reported on + * [reactionErrors]. + */ + suspend fun toggleReaction(chatId: ChatId, messageId: Long, emoji: String) + + /** Reaction failures the user should be told about, for a snackbar or similar. */ + val reactionErrors: SharedFlow + + /** + * Fetches confirmed reaction summaries for [messageIds] from the server and merges them into + * both the persisted store and [observeChatReactions] — for a page of messages that just + * entered view without one already arriving via the event stream or the initial load. + */ + suspend fun refreshReactions(chatId: ChatId, messageIds: List): Result + + /** One page of the users who reacted to [messageId] with [emoji], for the "who reacted" sheet. */ + suspend fun getReactorsPage( + chatId: ChatId, + messageId: Long, + emoji: String, + token: PagingToken? = null, + ): Result +} + /** * Resolves the [ChatId] of a DM from its participants, independent of any single * conversation. Two ways to arrive at an id: @@ -388,7 +452,8 @@ interface ChatCoordinator : EventStreamOperations, DmChatResolver, MessagingOperations, - GroupOperations { + GroupOperations, + ReactionOperations { /** Full observable snapshot of chat state (feed, typing, reactions, active chat). */ val state: StateFlow diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt index 6f2a37d78e..dc929f7392 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt @@ -18,11 +18,13 @@ import com.flipcash.shared.chat.EventStreamOperations import com.flipcash.shared.chat.FeedOperations import com.flipcash.shared.chat.GroupOperations import com.flipcash.shared.chat.MessagingOperations +import com.flipcash.shared.chat.ReactionOperations import com.flipcash.shared.chat.internal.delegates.EventStreamDelegate import com.flipcash.shared.chat.internal.delegates.FeedSyncDelegate import com.flipcash.shared.chat.internal.delegates.GroupFeedDelegate import com.flipcash.shared.chat.internal.delegates.DmChatResolverDelegate import com.flipcash.shared.chat.internal.delegates.MessagingDelegate +import com.flipcash.shared.chat.internal.delegates.ReactionsDelegate import com.getcode.opencode.model.accounts.AccountCluster import com.getcode.opencode.providers.SessionListener import com.getcode.utils.TraceType @@ -86,6 +88,7 @@ class RealChatCoordinator @Inject constructor( private val dmChatResolverDelegate: DmChatResolverDelegate, private val messagingDelegate: MessagingDelegate, private val groupFeedDelegate: GroupFeedDelegate, + private val reactionsDelegate: ReactionsDelegate, private val stateHolder: ChatStateHolder, private val draftStore: ChatDraftStore, private val userManager: UserManager, @@ -98,7 +101,8 @@ class RealChatCoordinator @Inject constructor( EventStreamOperations by eventStreamDelegate, DmChatResolver by dmChatResolverDelegate, MessagingOperations by messagingDelegate, - GroupOperations by groupFeedDelegate { + GroupOperations by groupFeedDelegate, + ReactionOperations by reactionsDelegate { companion object { private const val TAG = "ChatCoordinator" @@ -330,6 +334,7 @@ class RealChatCoordinator @Inject constructor( networkObserverJob?.cancel() stateHolder.reset() eventStreamDelegate.clearAll() + reactionsDelegate.clearAll() cluster.value = null supervisorJob.cancel() trace(tag = TAG, message = "teardown complete", type = TraceType.Process) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/ReactionsDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/ReactionsDelegate.kt new file mode 100644 index 0000000000..9a070f4022 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/ReactionsDelegate.kt @@ -0,0 +1,211 @@ +package com.flipcash.shared.chat.internal.delegates + +import com.flipcash.app.persistence.sources.ChatMessageDataSource +import com.flipcash.services.controllers.ChatMessagingController +import com.flipcash.services.models.AddReactionError +import com.flipcash.services.models.PagingToken +import com.flipcash.services.models.QueryOptions +import com.flipcash.services.models.RemoveReactionError +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.Emoji +import com.flipcash.services.models.chat.EmojiReaction +import com.flipcash.services.models.chat.ReactionSummary +import com.flipcash.services.models.chat.Reactor +import com.flipcash.services.repository.ReactorsPage +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.MessageReactions +import com.flipcash.shared.chat.ReactionOperations +import com.flipcash.shared.chat.internal.ChatStateHolder +import com.flipcash.shared.chat.reactions.ReactionCall +import com.flipcash.shared.chat.reactions.ReactionError +import com.flipcash.shared.chat.reactions.ReactionFailure +import com.flipcash.shared.chat.reactions.ReactionResult +import com.flipcash.shared.chat.reactions.ReactionState +import com.getcode.libs.emojis.reactions.RecentReactionsStore +import com.getcode.opencode.model.core.ID +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.update +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Clock + +/** + * Per-message reaction taps and lookups: toggling, refreshing confirmed state from the server, and + * paging the "who reacted" sheet. + * + * Holds one [ReactionState] per message this device has looked at — [snapshot] creates it lazily + * and folds in whatever [ChatStateHolder] currently has confirmed for that message (kept current by + * [EventStreamDelegate]'s own stream-driven reducer, and by [refreshReactions]'s explicit fetch). + * Only that fold is ever repeated; [ReactionState.applySummary] accepts by version, so replaying the + * same or older confirmed state over a pending tap never disturbs it. This mirrors the iOS + * reference (`ConversationReactions`): pending/in-flight state stays in memory only, and only the + * confirmed side is persisted, via [ChatMessageDataSource.mergeReactions]. + * + * @see com.flipcash.shared.chat.internal.RealChatCoordinator + */ +@Singleton +class ReactionsDelegate @Inject constructor( + private val messagingController: ChatMessagingController, + private val messageDataSource: ChatMessageDataSource, + private val recentReactionsStore: RecentReactionsStore, + private val userManager: UserManager, + private val stateHolder: ChatStateHolder, +) : ReactionOperations { + + /** One [ReactionState] per message this device has looked at, keyed by chat then message id. */ + private val liveStates: MutableMap> = mutableMapOf() + + /** + * Bumped on every local tap/respond so [observeChatReactions] re-emits — a pending tap or an + * in-flight call changes [liveStates] without touching [ChatStateHolder.state], which is the + * only thing [observeChatReactions] would otherwise be reacting to. + */ + private val localVersion = MutableStateFlow(0) + + private val _reactionErrors = MutableSharedFlow(extraBufferCapacity = 1) + override val reactionErrors: SharedFlow = _reactionErrors.asSharedFlow() + + override fun observeChatReactions(chatId: ChatId): Flow> = + combine(stateHolder.state, localVersion) { state, _ -> + val overlayIds = state.reactionOverlays[chatId]?.keys.orEmpty() + val liveIds = liveStates[chatId]?.keys.orEmpty() + (overlayIds + liveIds).associateWith { messageId -> + val reactionState = snapshot(chatId, messageId) + MessageReactions(pills = reactionState.pills, selfReactions = reactionState.selfReactions) + } + }.distinctUntilChanged() + + override suspend fun toggleReaction(chatId: ChatId, messageId: Long, emoji: String) { + val state = snapshot(chatId, messageId) + val call = state.tap(emoji, Clock.System.now()) + localVersion.update { it + 1 } + if (call != null) sendCall(chatId, messageId, state, call) + } + + override suspend fun refreshReactions(chatId: ChatId, messageIds: List): Result { + val summaries = messagingController.getReactionSummariesByIds(chatId, messageIds) + .getOrElse { return Result.failure(it) } + for (summary in summaries) { + val state = snapshot(chatId, summary.messageId) + state.applySummary(summary.reactions.map { it.toSummaryEntry() }) + persist(chatId, summary.messageId, state) + } + localVersion.update { it + 1 } + return Result.success(Unit) + } + + override suspend fun getReactorsPage( + chatId: ChatId, + messageId: Long, + emoji: String, + token: PagingToken?, + ): Result = + messagingController.getReactors(chatId, messageId, Emoji(emoji), QueryOptions(token = token)) + + /** Drops all in-memory reaction state (pending taps, in-flight calls, confirmed cache). */ + fun clearAll() { + liveStates.clear() + localVersion.value = 0 + } + + /** + * Sends [call], settles it against [state], persists confirmed state on success, records a + * successful add to the recents ranking, reports a non-silent failure on [reactionErrors], and + * loops once more if settling produced a coalesced follow-up call — the same shape as the iOS + * reference's tap → send → respond cycle. + */ + private suspend fun sendCall(chatId: ChatId, messageId: Long, state: ReactionState, call: ReactionCall) { + val result = performCall(chatId, messageId, call) + val (followUp, error) = state.respond(call.emoji, result) + if (result is ReactionResult.Ok) { + persist(chatId, messageId, state) + if (call.op == ReactionCall.Op.ADD) recentReactionsStore.record(call.emoji) + } + localVersion.update { it + 1 } + error?.let { _reactionErrors.emit(it) } + if (followUp != null) sendCall(chatId, messageId, state, followUp) + } + + private suspend fun performCall(chatId: ChatId, messageId: Long, call: ReactionCall): ReactionResult { + val emoji = Emoji(call.emoji) + val result = when (call.op) { + ReactionCall.Op.ADD -> messagingController.addReaction(chatId, messageId, emoji) + ReactionCall.Op.REMOVE -> messagingController.removeReaction(chatId, messageId, emoji) + } + return result.fold( + onSuccess = { reaction -> + ReactionResult.Ok( + count = reaction.count, + selfReacted = reaction.selfReactor != null, + version = reaction.version, + selfReactedAt = reaction.selfReactor?.reactedAt, + ) + }, + onFailure = { error -> ReactionResult.Failed(failureOf(call.op, error)) }, + ) + } + + private fun failureOf(op: ReactionCall.Op, error: Throwable): ReactionFailure = when (op) { + ReactionCall.Op.ADD -> when (error) { + is AddReactionError.Denied -> ReactionFailure.DENIED + is AddReactionError.MessageNotFound -> ReactionFailure.MESSAGE_NOT_FOUND + is AddReactionError.CannotReact -> ReactionFailure.CANNOT_REACT + is AddReactionError.TooManyReactionTypes -> ReactionFailure.TOO_MANY_REACTION_TYPES + else -> ReactionFailure.NETWORK + } + ReactionCall.Op.REMOVE -> when (error) { + is RemoveReactionError.Denied -> ReactionFailure.DENIED + is RemoveReactionError.MessageNotFound -> ReactionFailure.MESSAGE_NOT_FOUND + else -> ReactionFailure.NETWORK + } + } + + /** Gets or creates the message's [ReactionState] and folds in the latest confirmed overlay. */ + private fun snapshot(chatId: ChatId, messageId: Long): ReactionState { + val state = liveStates.getOrPut(chatId) { mutableMapOf() }.getOrPut(messageId) { ReactionState() } + stateHolder.current.reactionOverlays[chatId]?.get(messageId)?.let { summary -> + state.applySummary(summary.reactions.map { it.toSummaryEntry() }) + } + return state + } + + /** Persists [state]'s confirmed side and mirrors it into [ChatStateHolder.state]'s overlay. */ + private suspend fun persist(chatId: ChatId, messageId: Long, state: ReactionState) { + val selfId = userManager.accountId + val summary = ReactionSummary( + messageId = messageId, + reactions = state.summaryEntries.map { it.toEmojiReaction(selfId) }, + ) + messageDataSource.mergeReactions(chatId, messageId, summary) + stateHolder.update { current -> + val chatOverlays = (current.reactionOverlays[chatId] ?: emptyMap()) + (messageId to summary) + current.copy(reactionOverlays = current.reactionOverlays + (chatId to chatOverlays)) + } + } + + private fun EmojiReaction.toSummaryEntry(): ReactionState.SummaryEntry = ReactionState.SummaryEntry( + emoji = emoji.value, + count = count, + selfReacted = selfReactor != null, + version = version, + selfReactedAt = selfReactor?.reactedAt, + ) + + private fun ReactionState.SummaryEntry.toEmojiReaction(selfId: ID?): EmojiReaction = EmojiReaction( + emoji = Emoji(emoji), + count = count, + selfReactor = if (selfReacted && selfId != null) { + Reactor(userId = selfId, reactedAt = selfReactedAt ?: Clock.System.now(), version = version) + } else { + null + }, + sampleReactors = emptyList(), + version = version, + ) +} From f49733795251664cd3f553dee362cb3232c9a053 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 16:01:58 -0400 Subject: [PATCH 09/58] test(chat): cover ReactionsDelegate toggle, rollback, coalescing, and refresh --- .../shared/chat/ActiveChatHandoffTest.kt | 1 + .../chat/ChatCoordinatorEagerBalanceTest.kt | 1 + .../shared/chat/ChatCoordinatorEventsTest.kt | 1 + .../ChatCoordinatorLifecycleStreamTest.kt | 1 + .../chat/ChatCoordinatorTeardownTest.kt | 1 + .../shared/chat/ChatHistoryHydrationTest.kt | 1 + .../shared/chat/ChatMetadataEditStreamTest.kt | 1 + .../shared/chat/GroupChatRoutingTest.kt | 1 + .../chat/InterruptedSendRecoveryTest.kt | 1 + .../shared/chat/ReactionsDelegateTest.kt | 174 ++++++++++++++++++ .../shared/chat/ReceivedCounterTest.kt | 1 + .../flipcash/shared/chat/ReceivedEventTest.kt | 1 + .../shared/chat/ViewerStateStreamTest.kt | 1 + 13 files changed, 186 insertions(+) create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReactionsDelegateTest.kt diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ActiveChatHandoffTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ActiveChatHandoffTest.kt index 5647d6b0c3..bb8d229e44 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ActiveChatHandoffTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ActiveChatHandoffTest.kt @@ -122,6 +122,7 @@ class ActiveChatHandoffTest { networkObserver = mockk(relaxed = true), dispatchers = TestDispatchers(TestCoroutineScheduler()), groupFeedDelegate = mockk(relaxed = true), + reactionsDelegate = mockk(relaxed = true), ) } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEagerBalanceTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEagerBalanceTest.kt index bd82907572..e738df966c 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEagerBalanceTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEagerBalanceTest.kt @@ -135,6 +135,7 @@ class ChatCoordinatorEagerBalanceTest { networkObserver = mockk(relaxed = true), dispatchers = testDispatchers, groupFeedDelegate = mockk(relaxed = true), + reactionsDelegate = mockk(relaxed = true), ) } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt index 3dc26a14fc..661e469832 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt @@ -138,6 +138,7 @@ class ChatCoordinatorEventsTest { networkObserver = mockk(relaxed = true), dispatchers = testDispatchers, groupFeedDelegate = mockk(relaxed = true), + reactionsDelegate = mockk(relaxed = true), ) } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorLifecycleStreamTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorLifecycleStreamTest.kt index 1e5be5a24f..189856658a 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorLifecycleStreamTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorLifecycleStreamTest.kt @@ -149,6 +149,7 @@ class ChatCoordinatorLifecycleStreamTest { networkObserver = networkObserver, dispatchers = testDispatchers, groupFeedDelegate = mockk(relaxed = true), + reactionsDelegate = mockk(relaxed = true), ) } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt index e648ff7a99..290dcdf25a 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt @@ -118,6 +118,7 @@ class ChatCoordinatorTeardownTest { networkObserver = mockk(relaxed = true), dispatchers = TestDispatchers(TestCoroutineScheduler()), groupFeedDelegate = mockk(relaxed = true), + reactionsDelegate = mockk(relaxed = true), ) } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatHistoryHydrationTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatHistoryHydrationTest.kt index 1c90761e4e..7c24fe5002 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatHistoryHydrationTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatHistoryHydrationTest.kt @@ -148,6 +148,7 @@ class ChatHistoryHydrationTest { networkObserver = mockk(relaxed = true), dispatchers = TestDispatchers(testScheduler), groupFeedDelegate = mockk(relaxed = true), + reactionsDelegate = mockk(relaxed = true), ) } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatMetadataEditStreamTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatMetadataEditStreamTest.kt index cebb67b544..719da56c1d 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatMetadataEditStreamTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatMetadataEditStreamTest.kt @@ -133,6 +133,7 @@ class ChatMetadataEditStreamTest { networkObserver = mockk(relaxed = true), dispatchers = testDispatchers, groupFeedDelegate = mockk(relaxed = true), + reactionsDelegate = mockk(relaxed = true), ) } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupChatRoutingTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupChatRoutingTest.kt index cf6ece956d..b9a8d3b64e 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupChatRoutingTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupChatRoutingTest.kt @@ -125,6 +125,7 @@ class GroupChatRoutingTest { senderResolver = mockk(relaxed = true), ), groupFeedDelegate = groupFeedDelegate, + reactionsDelegate = mockk(relaxed = true), stateHolder = stateHolder, draftStore = mockk(relaxed = true), userManager = userManager, diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/InterruptedSendRecoveryTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/InterruptedSendRecoveryTest.kt index c3eb10e547..94bf026862 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/InterruptedSendRecoveryTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/InterruptedSendRecoveryTest.kt @@ -148,6 +148,7 @@ class InterruptedSendRecoveryTest { networkObserver = mockk(relaxed = true), dispatchers = testDispatchers, groupFeedDelegate = mockk(relaxed = true), + reactionsDelegate = mockk(relaxed = true), ) } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReactionsDelegateTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReactionsDelegateTest.kt new file mode 100644 index 0000000000..321c034305 --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReactionsDelegateTest.kt @@ -0,0 +1,174 @@ +package com.flipcash.shared.chat + +import com.flipcash.app.persistence.sources.ChatMessageDataSource +import com.flipcash.services.controllers.ChatMessagingController +import com.flipcash.services.models.AddReactionError +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.Emoji +import com.flipcash.services.models.chat.EmojiReaction +import com.flipcash.services.models.chat.ReactionSummary +import com.flipcash.services.models.chat.Reactor +import com.flipcash.services.repository.ReactorsPage +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.internal.ChatStateHolder +import com.flipcash.shared.chat.internal.delegates.ReactionsDelegate +import com.getcode.libs.emojis.reactions.RecentReactionsStore +import com.getcode.opencode.model.core.ID +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield +import org.junit.Before +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant + +class ReactionsDelegateTest { + + private lateinit var messagingController: ChatMessagingController + private lateinit var messageDataSource: ChatMessageDataSource + private lateinit var recentReactionsStore: RecentReactionsStore + private lateinit var stateHolder: ChatStateHolder + private lateinit var delegate: ReactionsDelegate + + private val chatId: ChatId = ChatId("aabbccdd") + private val selfId: ID = "self-user-id".encodeToByteArray().toList() + private val emoji = "😀" + + @Before + fun setup() { + messagingController = mockk(relaxed = true) + messageDataSource = mockk(relaxed = true) + recentReactionsStore = mockk(relaxed = true) + stateHolder = ChatStateHolder() + val userManager = mockk(relaxed = true) + every { userManager.accountId } returns selfId + + delegate = ReactionsDelegate( + messagingController = messagingController, + messageDataSource = messageDataSource, + recentReactionsStore = recentReactionsStore, + userManager = userManager, + stateHolder = stateHolder, + ) + } + + @Test + fun `toggle success adds a self pill, persists, and records recents`() = runTest { + coEvery { messagingController.addReaction(chatId, 1L, Emoji(emoji)) } returns Result.success( + EmojiReaction( + emoji = Emoji(emoji), + count = 1, + selfReactor = Reactor(userId = selfId, reactedAt = Instant.fromEpochSeconds(1000), version = 1), + sampleReactors = emptyList(), + version = 1, + ) + ) + + delegate.toggleReaction(chatId, 1L, emoji) + + val reactions = delegate.observeChatReactions(chatId).first() + val pills = reactions[1L]?.pills + assertEquals(1, pills?.size) + assertEquals(emoji, pills?.first()?.emoji) + assertTrue(pills?.first()?.selfReacted == true) + + coVerify { recentReactionsStore.record(emoji) } + coVerify { messageDataSource.mergeReactions(chatId, 1L, any()) } + } + + @Test + fun `toggle failure rolls back the pill and reports a non-silent error`() = runTest { + coEvery { messagingController.addReaction(chatId, 1L, Emoji(emoji)) } returns + Result.failure(AddReactionError.Denied()) + + var error: com.flipcash.shared.chat.reactions.ReactionError? = null + val collector = launch { error = delegate.reactionErrors.first() } + yield() + + delegate.toggleReaction(chatId, 1L, emoji) + collector.join() + + // Denied maps to REACTION_FAILED via ReactionFailure.userError. + assertEquals(com.flipcash.shared.chat.reactions.ReactionError.REACTION_FAILED, error) + + coVerify(exactly = 0) { messageDataSource.mergeReactions(any(), any(), any()) } + coVerify(exactly = 0) { recentReactionsStore.record(any()) } + } + + @Test + fun `a second tap while the first call is in flight coalesces into one follow-up call`() = runTest { + // The add call never completes within this test body — it's an uncompleted deferred result — + // so the second, opposite tap must be recorded rather than sent immediately. + val addDeferred = CompletableDeferred>() + coEvery { messagingController.addReaction(chatId, 1L, Emoji(emoji)) } coAnswers { addDeferred.await() } + coEvery { messagingController.removeReaction(chatId, 1L, Emoji(emoji)) } returns Result.success( + EmojiReaction(emoji = Emoji(emoji), count = 0, selfReactor = null, sampleReactors = emptyList(), version = 2) + ) + + val firstTap = launch { delegate.toggleReaction(chatId, 1L, emoji) } + yield() + // Second tap: toggles back off while the ADD is still in flight — must not send REMOVE yet. + delegate.toggleReaction(chatId, 1L, emoji) + coVerify(exactly = 0) { messagingController.removeReaction(any(), any(), any()) } + + addDeferred.complete( + Result.success( + EmojiReaction( + emoji = Emoji(emoji), + count = 1, + selfReactor = Reactor(userId = selfId, reactedAt = Instant.fromEpochSeconds(1000), version = 1), + sampleReactors = emptyList(), + version = 1, + ) + ) + ) + firstTap.join() + + // The coalesced REMOVE follow-up fires once the ADD settles. + coVerify { messagingController.removeReaction(chatId, 1L, Emoji(emoji)) } + } + + @Test + fun `refresh merges server summaries into the persisted store and the overlay`() = runTest { + val summary = ReactionSummary( + messageId = 1L, + reactions = listOf( + EmojiReaction( + emoji = Emoji(emoji), + count = 3, + selfReactor = null, + sampleReactors = emptyList(), + version = 5, + ) + ), + ) + coEvery { messagingController.getReactionSummariesByIds(chatId, listOf(1L)) } returns + Result.success(listOf(summary)) + + val result = delegate.refreshReactions(chatId, listOf(1L)) + + assertTrue(result.isSuccess) + coVerify { messageDataSource.mergeReactions(chatId, 1L, any()) } + val overlay = stateHolder.current.reactionOverlays[chatId]?.get(1L) + assertEquals(3, overlay?.reactions?.first()?.count) + } + + @Test + fun `getReactorsPage passes through to the controller`() = runTest { + coEvery { messagingController.getReactors(chatId, 1L, Emoji(emoji), any()) } returns + Result.success(ReactorsPage(reactors = emptyList(), hasMore = false)) + + val result = delegate.getReactorsPage(chatId, 1L, emoji) + + assertTrue(result.isSuccess) + assertNull(result.getOrNull()?.nextToken) + } +} diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedCounterTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedCounterTest.kt index 8ba346eeba..91677838ea 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedCounterTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedCounterTest.kt @@ -156,6 +156,7 @@ class ReceivedCounterTest { networkObserver = mockk(relaxed = true), dispatchers = testDispatchers, groupFeedDelegate = mockk(relaxed = true), + reactionsDelegate = mockk(relaxed = true), ) } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedEventTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedEventTest.kt index 0717b984b1..3f86b24c7e 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedEventTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedEventTest.kt @@ -162,6 +162,7 @@ class ReceivedEventTest { networkObserver = mockk(relaxed = true), dispatchers = testDispatchers, groupFeedDelegate = mockk(relaxed = true), + reactionsDelegate = mockk(relaxed = true), ) } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ViewerStateStreamTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ViewerStateStreamTest.kt index f7d535cf7e..65c5d085f7 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ViewerStateStreamTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ViewerStateStreamTest.kt @@ -136,6 +136,7 @@ class ViewerStateStreamTest { networkObserver = mockk(relaxed = true), dispatchers = testDispatchers, groupFeedDelegate = mockk(relaxed = true), + reactionsDelegate = mockk(relaxed = true), ) } From 351a2975fa6e10631ef7b1a0463a6121336680f1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 16:08:01 -0400 Subject: [PATCH 10/58] fix(chat): tombstone reactions a Room merge omits, require strictly newer version mergeReactionsJson previously kept a stored emoji as-is when an incoming payload (a server-fetched message copy) omitted it, leaving a stale pill once the server side had removed it. Now it tombstones the omitted emoji (count 0, self/sample reactors cleared, version kept) and only lets an incoming entry win on a strictly greater version, matching ReactionState.accept's tie-breaking. --- .../converters/ChatTypeConverters.kt | 17 +++++-- .../converters/ChatTypeConvertersTest.kt | 49 +++++++++++++++++++ .../app/persistence/dao/ChatMessageDaoTest.kt | 16 +++++- 3 files changed, 77 insertions(+), 5 deletions(-) diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt index 141d81e29b..ce9cd837bd 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt @@ -14,8 +14,13 @@ private val json = Json { } /** - * Merges a message's stored `reactions_json` with an incoming payload, per emoji, keeping - * whichever side carries the higher `version` — mirrors iOS's `mergeReactions`/`applySummary`. + * Merges a message's stored `reactions_json` with an incoming payload, per emoji — mirrors iOS's + * `applySummary`. `incoming` is treated as a full confirmed summary: an emoji [storedJson] holds + * that [incomingJson] omits has emptied, so it is kept as a tombstone (count 0, self/sample + * reactors cleared, `version` retained so a later, older add for it is still rejected) rather than + * left stale. An emoji present on both sides keeps whichever entry's `version` is strictly higher; + * a tie keeps [storedJson]'s entry, matching [com.flipcash.shared.chat.reactions.ReactionState]'s + * accept-by-version rule (`accept` rejects `<=`, so only a strictly newer version replaces it). * `incomingJson == null` keeps [storedJson] as-is: a write that carries no reaction data (an * ordinary content upsert) must not erase confirmed reactions already on disk. `storedJson == * null` (nothing stored yet) takes [incomingJson] outright. @@ -31,9 +36,13 @@ internal fun mergeReactionsJson(storedJson: String?, incomingJson: String?): Str val storedEntry = storedByEmoji[emoji] val incomingEntry = incomingByEmoji[emoji] when { - incomingEntry == null -> storedEntry + incomingEntry == null -> storedEntry?.copy( + count = 0, + selfReactor = null, + sampleReactors = emptyList(), + ) storedEntry == null -> incomingEntry - incomingEntry.version >= storedEntry.version -> incomingEntry + incomingEntry.version > storedEntry.version -> incomingEntry else -> storedEntry } } diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt index f6ce05d71b..d8c4be9902 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt @@ -183,4 +183,53 @@ class ChatTypeConvertersTest { } // endregion + + // region mergeReactionsJson + + private fun reactionsJson(vararg entries: Triple) = converter.toReactionSummary( + ReactionSummarySerialized( + messageId = 1, + reactions = entries.map { (emoji, count, version) -> + EmojiReactionSerialized( + emoji = emoji, + count = count, + selfReactor = if (count > 0) ReactorSerialized(userIdHex = "self", reactedAtEpochSeconds = 1L) else null, + sampleReactors = emptyList(), + version = version, + ) + }, + ) + )!! + + private fun decode(json: String?) = json?.let { converter.fromReactionSummary(it) } + + @Test + fun `mergeReactionsJson tombstones a stored emoji the incoming summary omits`() { + val stored = reactionsJson(Triple("👍", 1L, 1L), Triple("❤️", 1L, 3L)) + val incoming = reactionsJson(Triple("👍", 2L, 2L)) + + val merged = decode(mergeReactionsJson(stored, incoming))!! + val byEmoji = merged.reactions.associateBy { it.emoji } + + // The still-present emoji takes the incoming (newer) entry outright. + assertEquals(EmojiReactionSerialized("👍", 2L, byEmoji["👍"]?.selfReactor, emptyList(), 2L), byEmoji["👍"]) + // The omitted emoji becomes a tombstone: count 0, self/sample reactors cleared, version kept. + assertEquals( + EmojiReactionSerialized(emoji = "❤️", count = 0L, selfReactor = null, sampleReactors = emptyList(), version = 3L), + byEmoji["❤️"], + ) + } + + @Test + fun `mergeReactionsJson keeps the stored entry on a version tie`() { + val stored = reactionsJson(Triple("👍", 5L, 3L)) + val incoming = reactionsJson(Triple("👍", 1L, 3L)) + + val merged = decode(mergeReactionsJson(stored, incoming))!! + + // Strictly-greater only, matching ReactionState.accept: a tie does not let incoming win. + assertEquals(5L, merged.reactions.single { it.emoji == "👍" }.count) + } + + // endregion } diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt index 545e695c3c..4e27f053cb 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt @@ -387,6 +387,12 @@ class ChatMessageDaoTest { ?.associate { it.emoji to it.version } .orEmpty() + private fun countsByEmoji(json: String?) = json + ?.let { reactionJsonCodec.decodeFromString(it) } + ?.reactions + ?.associate { it.emoji to it.count } + .orEmpty() + /** * A plain content upsert (edit, delivery-status refresh, server echo) carries no reaction data * of its own. It must not wipe out a reaction a prior `ReactionUpdate` already confirmed. @@ -441,8 +447,12 @@ class ChatMessageDaoTest { assertNull(dao.getReactionsJson(CHAT_HEX, 99)) } + /** + * A stored emoji the incoming payload omits is treated as emptied on the server (mirrors iOS's + * `applySummary`), not left stale: it tombstones to count 0 while keeping its version. + */ @Test - fun `mergeReactionsJson keeps a stored emoji the incoming payload omits`() = runTest { + fun `mergeReactionsJson tombstones a stored emoji the incoming payload omits`() = runTest { dao.upsert(text(1, "hi")) dao.mergeReactionsJson(CHAT_HEX, 1, reactionsJson("👍" to 1, "❤️" to 1)) @@ -452,6 +462,10 @@ class ChatMessageDaoTest { mapOf("👍" to 2L, "❤️" to 1L), versionsByEmoji(dao.getReactionsJson(CHAT_HEX, 1)), ) + assertEquals( + mapOf("👍" to 2L, "❤️" to 0L), + countsByEmoji(dao.getReactionsJson(CHAT_HEX, 1)), + ) } // endregion From 9f90d75615ef0f4c9ceaba7a197fe86d936e30ea Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 16:12:24 -0400 Subject: [PATCH 11/58] fix(chat): seed ReactionsDelegate.snapshot from Room, not just the overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After relaunch, a confirmed self-reaction lives only in Room's reactions_json — ChatStateHolder's in-memory overlay starts empty. A freshly-created ReactionState picked up no prior confirmed state, so the first tap on an emoji already reacted to computed an ADD instead of a REMOVE. snapshot now seeds a new state from ChatMessageDataSource before folding in the overlay. --- .../internal/delegates/ReactionsDelegate.kt | 28 +++++++++++--- .../shared/chat/ReactionsDelegateTest.kt | 38 +++++++++++++++++++ 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/ReactionsDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/ReactionsDelegate.kt index 9a070f4022..9890c0680f 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/ReactionsDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/ReactionsDelegate.kt @@ -75,10 +75,13 @@ class ReactionsDelegate @Inject constructor( combine(stateHolder.state, localVersion) { state, _ -> val overlayIds = state.reactionOverlays[chatId]?.keys.orEmpty() val liveIds = liveStates[chatId]?.keys.orEmpty() - (overlayIds + liveIds).associateWith { messageId -> + val ids = overlayIds + liveIds + val result = mutableMapOf() + for (messageId in ids) { val reactionState = snapshot(chatId, messageId) - MessageReactions(pills = reactionState.pills, selfReactions = reactionState.selfReactions) + result[messageId] = MessageReactions(pills = reactionState.pills, selfReactions = reactionState.selfReactions) } + result }.distinctUntilChanged() override suspend fun toggleReaction(chatId: ChatId, messageId: Long, emoji: String) { @@ -166,9 +169,24 @@ class ReactionsDelegate @Inject constructor( } } - /** Gets or creates the message's [ReactionState] and folds in the latest confirmed overlay. */ - private fun snapshot(chatId: ChatId, messageId: Long): ReactionState { - val state = liveStates.getOrPut(chatId) { mutableMapOf() }.getOrPut(messageId) { ReactionState() } + /** + * Gets or creates the message's [ReactionState] and folds in the latest confirmed overlay. + * + * A freshly-created state is first seeded from Room: after a relaunch the confirmed reactions + * for a message this device already knew about live only on disk, not in [ChatStateHolder] — + * without this, the first tap on an emoji the user already reacted with would compute an ADD + * instead of a REMOVE. Only done once per message (the `getOrPut` below only runs the seed + * block when creating), since after that [ChatStateHolder]'s overlay (kept current by + * [EventStreamDelegate] and [refreshReactions]) is the live source of truth. + */ + private suspend fun snapshot(chatId: ChatId, messageId: Long): ReactionState { + val chatStates = liveStates.getOrPut(chatId) { mutableMapOf() } + val state = chatStates[messageId] ?: ReactionState().also { fresh -> + messageDataSource.getMessage(chatId, messageId)?.reactions?.let { summary -> + fresh.applySummary(summary.reactions.map { it.toSummaryEntry() }) + } + chatStates[messageId] = fresh + } stateHolder.current.reactionOverlays[chatId]?.get(messageId)?.let { summary -> state.applySummary(summary.reactions.map { it.toSummaryEntry() }) } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReactionsDelegateTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReactionsDelegateTest.kt index 321c034305..cc61c9c299 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReactionsDelegateTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReactionsDelegateTest.kt @@ -4,6 +4,7 @@ import com.flipcash.app.persistence.sources.ChatMessageDataSource import com.flipcash.services.controllers.ChatMessagingController import com.flipcash.services.models.AddReactionError import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.Emoji import com.flipcash.services.models.chat.EmojiReaction import com.flipcash.services.models.chat.ReactionSummary @@ -161,6 +162,43 @@ class ReactionsDelegateTest { assertEquals(3, overlay?.reactions?.first()?.count) } + /** + * After relaunch, a confirmed self-reaction lives only in Room — [ChatStateHolder]'s in-memory + * overlay is empty. Tapping that emoji must still compute a REMOVE, not an ADD: `snapshot` has + * to seed the fresh [com.flipcash.shared.chat.reactions.ReactionState] from the stored summary, + * not just from the overlay. + */ + @Test + fun `toggling an emoji already reacted to in Room, with no overlay, sends a remove`() = runTest { + coEvery { messageDataSource.getMessage(chatId, 1L) } returns ChatMessage( + messageId = 1L, + senderId = selfId, + content = emptyList(), + timestamp = Instant.fromEpochSeconds(1000), + unreadSeq = 0, + reactions = ReactionSummary( + messageId = 1L, + reactions = listOf( + EmojiReaction( + emoji = Emoji(emoji), + count = 1, + selfReactor = Reactor(userId = selfId, reactedAt = Instant.fromEpochSeconds(1000), version = 1), + sampleReactors = emptyList(), + version = 1, + ) + ), + ), + ) + coEvery { messagingController.removeReaction(chatId, 1L, Emoji(emoji)) } returns Result.success( + EmojiReaction(emoji = Emoji(emoji), count = 0, selfReactor = null, sampleReactors = emptyList(), version = 2) + ) + + delegate.toggleReaction(chatId, 1L, emoji) + + coVerify { messagingController.removeReaction(chatId, 1L, Emoji(emoji)) } + coVerify(exactly = 0) { messagingController.addReaction(any(), any(), any()) } + } + @Test fun `getReactorsPage passes through to the controller`() = runTest { coEvery { messagingController.getReactors(chatId, 1L, Emoji(emoji), any()) } returns From e6f936cf7c47df78df4fb9c34c82928ed1c3e533 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 16:15:37 -0400 Subject: [PATCH 12/58] fix(chat): add MessageReactions.from for messages outside live overlays observeChatReactions only ever covered messages with a pending tap or a confirmed overlay from the event stream/refreshReactions; a message whose reactions live only in a paged Room read had no way to become pills. Document that contract on the method and give the ViewModel a pure fallback - MessageReactions.from(summary) runs a ChatMessage's persisted reactions through ReactionState.applySummary so tombstone filtering and pill order match the live path exactly. --- .../flipcash/shared/chat/ChatCoordinator.kt | 48 +++++++++++- .../shared/chat/MessageReactionsTest.kt | 74 +++++++++++++++++++ 2 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessageReactionsTest.kt diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt index 9919fba5ff..28d377cb22 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt @@ -97,7 +97,35 @@ interface EventStreamOperations { data class MessageReactions( val pills: List, val selfReactions: List, -) +) { + companion object { + /** + * Builds [MessageReactions] straight from a persisted [ReactionSummary] — no pending taps, + * no in-flight calls. This is the ViewModel's fallback for a message + * [ReactionOperations.observeChatReactions] has no live override for: apply this to + * [com.flipcash.services.models.chat.ChatMessage.reactions] and let a live override, when + * present, win. Goes through [com.flipcash.shared.chat.reactions.ReactionState] so the + * count-0-tombstone filtering and pill ordering match the live path exactly. + */ + fun from(summary: ReactionSummary?): MessageReactions { + val state = com.flipcash.shared.chat.reactions.ReactionState() + summary?.let { + state.applySummary( + it.reactions.map { reaction -> + com.flipcash.shared.chat.reactions.ReactionState.SummaryEntry( + emoji = reaction.emoji.value, + count = reaction.count, + selfReacted = reaction.selfReactor != null, + version = reaction.version, + selfReactedAt = reaction.selfReactor?.reactedAt, + ) + }, + ) + } + return MessageReactions(pills = state.pills, selfReactions = state.selfReactions) + } + } +} /** * Reaction taps and lookups for a single message: toggling, refreshing confirmed state from the @@ -107,9 +135,21 @@ data class MessageReactions( */ interface ReactionOperations { /** - * Reactive per-message reaction state for every message in [chatId] that has one — confirmed - * server state (persisted, and kept current by the event stream) merged with this device's own - * pending taps and in-flight calls, keyed by message id. + * Reactive **live overrides** for reactions in [chatId], keyed by message id — not one entry + * per message in the chat. A message appears here once this device has a pending tap, an + * in-flight call, or a confirmed overlay for it (an [EventStreamOperations] reaction update, or + * an explicit [refreshReactions]); a message whose reactions have only ever been seen via a + * paged Room read, or attached to a server-fetched [com.flipcash.services.models.chat.ChatMessage], + * is absent from this map even though it may well have reactions. + * + * The **ViewModel is responsible for completeness**: for a message missing from this map, fall + * back to [MessageReactions.from] applied to that message's own + * [com.flipcash.services.models.chat.ChatMessage.reactions] (the Room-decoded confirmed + * summary), and let an entry here win when both exist. This works because a write to + * `reactions_json` (a merge, or a plain message upsert that carries a summary) goes through + * Room, and Room invalidates the `chat_messages` `PagingSource` on any write to that table, so + * the paged message list already re-emits with the fresh summary — no separate signal needed + * for the Room-only case. */ fun observeChatReactions(chatId: ChatId): Flow> diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessageReactionsTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessageReactionsTest.kt new file mode 100644 index 0000000000..1cb3248dc6 --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessageReactionsTest.kt @@ -0,0 +1,74 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.models.chat.Emoji +import com.flipcash.services.models.chat.EmojiReaction +import com.flipcash.services.models.chat.ReactionSummary +import com.flipcash.services.models.chat.Reactor +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * Covers [MessageReactions.from] — the pure fallback the ViewModel applies to a [ChatMessage]'s + * persisted `reactions` for a message [ReactionOperations.observeChatReactions] has no live + * override for (see that function's KDoc for the full contract). + */ +class MessageReactionsTest { + + @Test + fun `from builds pills from a persisted summary with no live overrides`() { + val summary = ReactionSummary( + messageId = 1L, + reactions = listOf( + EmojiReaction( + emoji = Emoji("👍"), + count = 2, + selfReactor = Reactor( + userId = listOf(1), + reactedAt = Instant.fromEpochSeconds(10), + version = 1, + ), + sampleReactors = emptyList(), + version = 1, + ), + ), + ) + + val reactions = MessageReactions.from(summary) + + assertEquals(1, reactions.pills.size) + assertEquals("👍", reactions.pills.first().emoji) + assertEquals(2, reactions.pills.first().count) + assertTrue(reactions.pills.first().selfReacted) + assertEquals(listOf("👍"), reactions.selfReactions.map { it.emoji }) + } + + @Test + fun `from a null summary is empty`() { + val reactions = MessageReactions.from(null) + + assertTrue(reactions.pills.isEmpty()) + assertTrue(reactions.selfReactions.isEmpty()) + } + + @Test + fun `from drops a tombstoned emoji`() { + val summary = ReactionSummary( + messageId = 1L, + reactions = listOf( + EmojiReaction( + emoji = Emoji("❤️"), + count = 0, + selfReactor = null, + sampleReactors = emptyList(), + version = 3, + ), + ), + ) + + val reactions = MessageReactions.from(summary) + + assertTrue(reactions.pills.isEmpty()) + } +} From f96f2e55aeb1d84b72ae6811e751a28e43caf204 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 16:26:07 -0400 Subject: [PATCH 13/58] feat(chat): add canReact predicate for reaction eligibility --- .../flipcash/shared/chat/MessageCapability.kt | 19 +++++++++ .../shared/chat/MessageCapabilityTest.kt | 42 +++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageCapability.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageCapability.kt index 47a42a722c..fcad28120c 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageCapability.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageCapability.kt @@ -205,3 +205,22 @@ fun Set.withinWindows( /** True while a message sent at [sentAt] is inside this window, or always if there is none. */ private fun Duration?.stillOpen(sentAt: Instant, now: Instant): Boolean = this == null || now - sentAt <= this + +/** + * Whether [message] may be reacted to. + * + * Reactions have no edit/delete-style windows and no report-only carve-out: anyone's message is + * reactable, own or another participant's, text or cash. Only two things rule a message out — + * a system notice (nothing a participant sent) and an unconfirmed send (`eventSequence == 0`, + * the same guard [resolveForParticipant] uses: no valid request can name a message the server + * has not acknowledged). A tombstone has no content left ([MessageContent.Deleted] clears it), + * so the empty-content check below already excludes it. + */ +fun canReact(message: ChatMessage): Boolean { + val contents = message.content + if (contents.isEmpty()) return false + if (contents.any { it is MessageContent.Deleted }) return false + if (message.eventSequence == 0L) return false + if (contents.all { it is MessageContent.System }) return false + return true +} diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessageCapabilityTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessageCapabilityTest.kt index f8d348756b..aa51bc0cd3 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessageCapabilityTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessageCapabilityTest.kt @@ -345,4 +345,46 @@ class MessageCapabilityTest { resolved.readOnly(), ) } + + // canReact — reactions have no window and no report-only carve-out, so the only thing they + // share with resolveCapabilities is which messages don't exist as far as an action is + // concerned: tombstones, system notices, and unconfirmed sends. + @Test + fun `own and another participant's text are both reactable`() { + assertEquals(true, canReact(text())) + assertEquals(true, canReact(text(isFromSelf = false))) + } + + @Test + fun `own and another participant's cash are both reactable`() { + assertEquals(true, canReact(cash())) + assertEquals(true, canReact(cash(isFromSelf = false))) + } + + @Test + fun `an old message stays reactable, unlike edit or delete`() { + assertEquals(true, canReact(text(isFromSelf = false))) + } + + @Test + fun `a tombstone is not reactable`() { + val deleted = message(listOf(MessageContent.Deleted(sentAt, selfId))) + assertEquals(false, canReact(deleted)) + } + + @Test + fun `a system notice is not reactable`() { + val system = message(listOf(MessageContent.System("Anna joined"))) + assertEquals(false, canReact(system)) + } + + @Test + fun `an unconfirmed send is not reactable`() { + assertEquals(false, canReact(text(eventSequence = 0))) + } + + @Test + fun `empty content is not reactable`() { + assertEquals(false, canReact(message(emptyList()))) + } } From a6121e0c9f01998139ee2dd2fb9d1a22a3063f16 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 16:31:25 -0400 Subject: [PATCH 14/58] feat(chat-ui): add ReactionPillRow with N-more collapse layout --- .../shared/chat/ui/ReactionPillRow.kt | 328 ++++++++++++++++++ .../chat/ui/ReactionPillRowLayoutTest.kt | 108 ++++++ 2 files changed, 436 insertions(+) create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionPillRow.kt create mode 100644 apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ReactionPillRowLayoutTest.kt diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionPillRow.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionPillRow.kt new file mode 100644 index 0000000000..b5f1e47543 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionPillRow.kt @@ -0,0 +1,328 @@ +package com.flipcash.shared.chat.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.flipcash.shared.chat.reactions.ReactionPill +import com.getcode.theme.CodeTheme + +/** + * The N-more collapse the pill row uses under a bubble (decision 2): pack pills onto up to + * [maxLines] lines of [containerWidth], and when they don't all fit, show as many as fit + * alongside a trailing "N more" pill (itself sized [moreWidth], resolved by the caller from the + * real count) rather than clipping a pill mid-row. + * + * A pure measure function so the N-more decision is testable without standing up Compose: given + * the same child widths and container width, it always returns the same split. [ReactionPillRow] + * is the only caller — it measures real pill widths, asks this, and lays out accordingly. + */ +object ReactionPillRowLayout { + + data class Result( + /** How many of [pillWidths], from the front, are drawn. */ + val visibleCount: Int, + /** Whether a trailing "N more" pill is needed. */ + val showsMore: Boolean, + /** How many pills the "N more" pill stands in for. Zero when [showsMore] is false. */ + val moreCount: Int, + ) + + /** + * @param pillWidths each reaction pill's measured width, in the same order the row draws them. + * @param moreWidth the "N more" pill's width for the count this call would show. The caller + * only knows that count once this returns, so it re-measures the real chip only when + * [Result.showsMore] comes back true — everything up to then uses an upper-bound estimate + * (a chip sized for the worst case, `pillWidths.size` digits), which never under-reserves. + * @param plusWidth the trailing "+" button's width, or `null` when `canReact` hides it. + * @param expanded true once "N more" has been tapped: every pill draws, unbounded lines, and + * nothing collapses again this composition. + */ + fun compute( + pillWidths: List, + moreWidth: Int, + plusWidth: Int?, + containerWidth: Int, + spacing: Int, + maxLines: Int, + expanded: Boolean, + ): Result { + if (containerWidth <= 0 || pillWidths.isEmpty()) { + return Result(visibleCount = pillWidths.size, showsMore = false, moreCount = 0) + } + + val effectiveMaxLines = if (expanded) Int.MAX_VALUE else maxLines + + if (fits(pillWidths, plusWidth, containerWidth, spacing, effectiveMaxLines)) { + return Result(visibleCount = pillWidths.size, showsMore = false, moreCount = 0) + } + + // Collapsed and everything didn't fit: back off from the full set until what's left, plus + // a trailing "N more" pill (and the "+" beyond it), fits within the line budget. + for (visible in pillWidths.size - 1 downTo 0) { + val widths = pillWidths.subList(0, visible) + moreWidth + if (fits(widths, plusWidth, containerWidth, spacing, effectiveMaxLines)) { + return Result( + visibleCount = visible, + showsMore = true, + moreCount = pillWidths.size - visible, + ) + } + } + return Result(visibleCount = 0, showsMore = true, moreCount = pillWidths.size) + } + + /** + * Whether [widths], followed by [trailingWidth] (the "+" button, when present), packs into + * [maxLines] lines of [containerWidth] with [spacing] between adjacent items on a line — the + * same greedy left-to-right wrap `FlowRow` performs, just run ahead of time on known widths. + */ + private fun fits( + widths: List, + trailingWidth: Int?, + containerWidth: Int, + spacing: Int, + maxLines: Int, + ): Boolean { + var line = 0 + var remaining = containerWidth + var lineHasItem = false + + fun place(width: Int): Boolean { + val needed = width + if (lineHasItem) spacing else 0 + if (needed <= remaining) { + remaining -= needed + lineHasItem = true + return true + } + line++ + if (line >= maxLines) return false + remaining = containerWidth - width + lineHasItem = true + return true + } + + for (width in widths) { + if (!place(width)) return false + } + if (trailingWidth != null && !place(trailingWidth)) return false + return true + } +} + +/** + * The pill row drawn under a bubble (decision 2): up to two lines of [pills], collapsing into an + * "N more" pill before the trailing "+" when they don't fit, aligned to [alignEnd] (the sender's + * side — end for the viewer's own messages). + * + * A pill tap toggles that reaction; a pill long-press opens the reactors sheet and must not also + * select the message — its own `combinedClickable` handles the long-press so it never reaches the + * bubble's. "N more" expands the row in place — every pill then draws, unbounded lines — and + * stays expanded for the rest of this composition. "+" (hidden when `canReact` is false) opens + * the reaction picker. + */ +@Composable +fun ReactionPillRow( + pills: List, + canReact: Boolean, + onToggle: (emoji: String) -> Unit, + onPillLongClick: () -> Unit, + onOpenPicker: () -> Unit, + modifier: Modifier = Modifier, + alignEnd: Boolean = false, + maxLines: Int = 2, +) { + if (pills.isEmpty() && !canReact) return + + var expanded by remember(pills) { mutableStateOf(false) } + + val pillHeight = 28.dp + val pillPadding = 10.dp + val spacingDp = 6.dp + val plusSize = 28.dp + val topGap = 4.dp + + SubcomposeLayout(modifier = modifier.padding(top = topGap)) { constraints -> + val loose = constraints.copy(minWidth = 0, minHeight = 0) + val spacingPx = spacingDp.roundToPx() + val containerWidth = constraints.maxWidth + + val pillPlaceables = subcompose("pills") { + pills.forEach { pill -> + ReactionPillChip( + pill = pill, + height = pillHeight, + horizontalPadding = pillPadding, + onClick = { onToggle(pill.emoji) }, + onLongClick = onPillLongClick, + ) + } + }.map { it.measure(loose) } + + val plusPlaceable = if (canReact) { + subcompose("plus") { PlusChip(size = plusSize, onClick = onOpenPicker) } + .first().measure(loose) + } else null + + // Upper-bound estimate: a "more" chip sized for showing every pill, never narrower than + // the real chip this could resolve to. + val moreEstimate = subcompose("more-estimate") { + MorePillChip(count = pills.size, height = pillHeight, horizontalPadding = pillPadding) + }.first().measure(loose) + + val result = ReactionPillRowLayout.compute( + pillWidths = pillPlaceables.map { it.width }, + moreWidth = moreEstimate.width, + plusWidth = plusPlaceable?.width, + containerWidth = containerWidth, + spacing = spacingPx, + maxLines = maxLines, + expanded = expanded, + ) + + val morePlaceable = if (result.showsMore) { + subcompose("more") { + MorePillChip( + count = result.moreCount, + height = pillHeight, + horizontalPadding = pillPadding, + onClick = { expanded = true }, + ) + }.first().measure(loose) + } else null + + val visible = pillPlaceables.take(result.visibleCount) + val items = visible + listOfNotNull(morePlaceable, plusPlaceable) + + // Greedy wrap identical to ReactionPillRowLayout.fits, now placing real placeables: first + // assign each item an (x, line) within its line, tracking each line's total width and + // height, then place with a per-line offset so an end-aligned row hugs the container's + // trailing edge rather than its leading one. + data class Placement(val item: androidx.compose.ui.layout.Placeable, val x: Int, val line: Int) + + val placements = ArrayList(items.size) + val lineWidths = ArrayList() + val lineHeights = ArrayList() + var x = 0 + var line = 0 + var lineHasItem = false + var lineHeight = 0 + for (item in items) { + val needed = item.width + if (lineHasItem) spacingPx else 0 + if (lineHasItem && x + needed > containerWidth) { + lineWidths.add(x) + lineHeights.add(lineHeight) + line++ + x = 0 + lineHeight = 0 + lineHasItem = false + } + if (lineHasItem) x += spacingPx + placements.add(Placement(item, x, line)) + x += item.width + lineHeight = maxOf(lineHeight, item.height) + lineHasItem = true + } + lineWidths.add(x) + lineHeights.add(lineHeight) + + val lineY = IntArray(lineHeights.size) + for (i in 1 until lineHeights.size) lineY[i] = lineY[i - 1] + lineHeights[i - 1] + spacingPx + val totalHeight = lineY.last() + lineHeights.last() + + layout(containerWidth, totalHeight) { + for (placement in placements) { + val lineOffset = if (alignEnd) containerWidth - lineWidths[placement.line] else 0 + placement.item.placeRelative(placement.x + lineOffset, lineY[placement.line]) + } + } + } +} + +@Composable +private fun ReactionPillChip( + pill: ReactionPill, + height: Dp, + horizontalPadding: Dp, + onClick: () -> Unit, + onLongClick: () -> Unit, +) { + val fill = if (pill.selfReacted) { + Color.White.copy(alpha = 0.12f) + } else { + Color.White.copy(alpha = 0.06f) + } + Box( + modifier = Modifier + .background(color = fill, shape = RoundedCornerShape(percent = 50)) + .let { base -> + if (pill.selfReacted) { + base.border( + width = CodeTheme.dimens.thickBorder, + color = Color.White.copy(alpha = 0.24f), + shape = RoundedCornerShape(percent = 50), + ) + } else { + base + } + } + .combinedClickable(onClick = onClick, onLongClick = onLongClick) + .padding(horizontal = horizontalPadding), + contentAlignment = Alignment.Center, + ) { + Text(text = "${pill.emoji} ${pill.count}", fontSize = 13.sp, textAlign = TextAlign.Center) + } +} + +@Composable +private fun MorePillChip( + count: Int, + height: Dp, + horizontalPadding: Dp, + onClick: (() -> Unit)? = null, +) { + Box( + modifier = Modifier + .background( + color = Color.White.copy(alpha = 0.06f), + shape = RoundedCornerShape(percent = 50), + ) + .let { base -> onClick?.let { base.combinedClickable(onClick = it, onLongClick = {}) } ?: base } + .padding(horizontal = horizontalPadding), + contentAlignment = Alignment.Center, + ) { + Text(text = "$count more", fontSize = 13.sp) + } +} + +@Composable +private fun PlusChip(size: Dp, onClick: () -> Unit) { + Box( + modifier = Modifier + .background( + color = Color.White.copy(alpha = 0.06f), + shape = RoundedCornerShape(percent = 50), + ) + .combinedClickable(onClick = onClick, onLongClick = {}), + contentAlignment = Alignment.Center, + ) { + Text(text = "+", fontSize = 15.sp) + } +} diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ReactionPillRowLayoutTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ReactionPillRowLayoutTest.kt new file mode 100644 index 0000000000..1daa69e9ab --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ReactionPillRowLayoutTest.kt @@ -0,0 +1,108 @@ +package com.flipcash.shared.chat.ui + +import org.junit.Test +import kotlin.test.assertEquals + +/** + * [ReactionPillRowLayout.compute]'s N-more collapse: pills wrap onto up to two lines, and when + * they don't all fit, the row shows as many as fit alongside a trailing "N more" pill rather than + * clipping one mid-row. + */ +class ReactionPillRowLayoutTest { + + private val spacing = 6 + + @Test + fun `everything fits on one line shows no more pill`() { + val result = ReactionPillRowLayout.compute( + pillWidths = listOf(40, 40, 40), + moreWidth = 60, + plusWidth = 28, + containerWidth = 300, + spacing = spacing, + maxLines = 2, + expanded = false, + ) + assertEquals(ReactionPillRowLayout.Result(visibleCount = 3, showsMore = false, moreCount = 0), result) + } + + @Test + fun `pills spilling past two lines collapse into a more pill`() { + // Six 60-wide pills at spacing 6 need three lines of 100 width; two lines fit two pills each. + val result = ReactionPillRowLayout.compute( + pillWidths = List(6) { 60 }, + moreWidth = 50, + plusWidth = 28, + containerWidth = 130, + spacing = spacing, + maxLines = 2, + expanded = false, + ) + // Two lines of 130 fit two 60-wide pills each (60, then 60+6=66 more) with 4px to spare — + // not enough for a third 60-wide pill (66 > 4), so line one holds one pill's worth of + // room short of two; the reference math above is why the collapse lands at exactly 2 kept + // pills, 4 folded into "N more". + assertEquals(ReactionPillRowLayout.Result(visibleCount = 2, showsMore = true, moreCount = 4), result) + } + + @Test + fun `expanded ignores the line limit and shows every pill`() { + val result = ReactionPillRowLayout.compute( + pillWidths = List(20) { 60 }, + moreWidth = 50, + plusWidth = 28, + containerWidth = 130, + spacing = spacing, + maxLines = 2, + expanded = true, + ) + assertEquals(ReactionPillRowLayout.Result(visibleCount = 20, showsMore = false, moreCount = 0), result) + } + + @Test + fun `no plus button still collapses correctly`() { + val result = ReactionPillRowLayout.compute( + pillWidths = List(6) { 60 }, + moreWidth = 50, + plusWidth = null, + containerWidth = 130, + spacing = spacing, + maxLines = 2, + expanded = false, + ) + assertEquals(true, result.showsMore) + assertEquals(6, result.visibleCount + result.moreCount) + } + + @Test + fun `empty pills show nothing`() { + val result = ReactionPillRowLayout.compute( + pillWidths = emptyList(), + moreWidth = 50, + plusWidth = 28, + containerWidth = 130, + spacing = spacing, + maxLines = 2, + expanded = false, + ) + assertEquals(ReactionPillRowLayout.Result(visibleCount = 0, showsMore = false, moreCount = 0), result) + } + + @Test + fun `single pill wider than the container still shows alone`() { + val result = ReactionPillRowLayout.compute( + pillWidths = listOf(500), + moreWidth = 50, + plusWidth = 28, + containerWidth = 130, + spacing = spacing, + maxLines = 2, + expanded = false, + ) + // The pill alone already leaves no room for the trailing "+" within two lines, so it + // collapses to a lone "more" pill that does have room for the "+" beside it. + assertEquals(1, result.visibleCount + result.moreCount) + assertEquals(0, result.visibleCount) + assertEquals(true, result.showsMore) + } +} From bdd7abf45a9b0e30e63a77c921ffed1f2dced6bf Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 16:32:52 -0400 Subject: [PATCH 15/58] feat(chat): add ReactionRefreshPlanner for paging-driven reaction refresh --- .../chat/reactions/ReactionRefreshPlanner.kt | 54 ++++++++++++++++ .../reactions/ReactionRefreshPlannerTest.kt | 63 +++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactionRefreshPlanner.kt create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/reactions/ReactionRefreshPlannerTest.kt diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactionRefreshPlanner.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactionRefreshPlanner.kt new file mode 100644 index 0000000000..469c4ea743 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactionRefreshPlanner.kt @@ -0,0 +1,54 @@ +package com.flipcash.shared.chat.reactions + +/** + * Decides which message ids need a reaction refresh (a [com.flipcash.shared.chat.ChatCoordinator.refreshReactions] + * call), and how to batch that call. + * + * Two entry points, matching the two places a hook fires: + * - [initialWindow]: chat opens, or the app/stream comes back to the foreground — refresh the + * newest messages currently loaded, since a live overlay may have been missed while backgrounded. + * - [forLoadedPage]: a new page of *older* messages lands in the paging list. Only messages that + * were read out of Room (already-cached rows the pager attached, not a fresh server page) need a + * refresh — a page the RemoteMediator just fetched from the network already carries current + * reaction state, so refreshing it again would be redundant. + * + * Both are pure: callers own the "already refreshed" bookkeeping and pass in what's still needed. + */ +object ReactionRefreshPlanner { + + const val DEFAULT_WINDOW_SIZE = 60 + const val DEFAULT_CHUNK_SIZE = 40 + + /** + * @param newestFirstIds all currently loaded message ids, newest first. + * @param alreadyRefreshed ids this session has already refreshed and doesn't need to repeat. + */ + fun initialWindow( + newestFirstIds: List, + alreadyRefreshed: Set = emptySet(), + windowSize: Int = DEFAULT_WINDOW_SIZE, + ): List = + newestFirstIds + .asSequence() + .take(windowSize) + .filterNot { it in alreadyRefreshed } + .toList() + + /** + * @param pageIds ids in the page that just loaded. + * @param sourcedFromServer true when this page came from a RemoteMediator network fetch rather + * than an existing Room row; server-fetched pages are skipped. + * @return chunks of ids to refresh, each no larger than [chunkSize]. + */ + fun forLoadedPage( + pageIds: List, + sourcedFromServer: Boolean, + alreadyRefreshed: Set = emptySet(), + chunkSize: Int = DEFAULT_CHUNK_SIZE, + ): List> { + if (sourcedFromServer) return emptyList() + val remaining = pageIds.filterNot { it in alreadyRefreshed } + if (remaining.isEmpty()) return emptyList() + return remaining.chunked(chunkSize) + } +} diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/reactions/ReactionRefreshPlannerTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/reactions/ReactionRefreshPlannerTest.kt new file mode 100644 index 0000000000..afecbb764f --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/reactions/ReactionRefreshPlannerTest.kt @@ -0,0 +1,63 @@ +package com.flipcash.shared.chat.reactions + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ReactionRefreshPlannerTest { + + @Test + fun `initial window caps at window size`() { + val ids = (1L..100L).toList() + val window = ReactionRefreshPlanner.initialWindow(ids, windowSize = 60) + assertEquals(60, window.size) + assertEquals(ids.take(60), window) + } + + @Test + fun `initial window under window size returns all`() { + val ids = (1L..10L).toList() + assertEquals(ids, ReactionRefreshPlanner.initialWindow(ids, windowSize = 60)) + } + + @Test + fun `initial window excludes already refreshed ids`() { + val ids = listOf(1L, 2L, 3L, 4L, 5L) + val window = ReactionRefreshPlanner.initialWindow(ids, alreadyRefreshed = setOf(2L, 4L), windowSize = 60) + assertEquals(listOf(1L, 3L, 5L), window) + } + + @Test + fun `loaded page from server is skipped entirely`() { + val ids = (1L..100L).toList() + val chunks = ReactionRefreshPlanner.forLoadedPage(ids, sourcedFromServer = true) + assertEquals(emptyList>(), chunks) + } + + @Test + fun `loaded page from room is chunked`() { + val ids = (1L..100L).toList() + val chunks = ReactionRefreshPlanner.forLoadedPage(ids, sourcedFromServer = false, chunkSize = 40) + assertEquals(3, chunks.size) + assertEquals(40, chunks[0].size) + assertEquals(40, chunks[1].size) + assertEquals(20, chunks[2].size) + assertEquals(ids, chunks.flatten()) + } + + @Test + fun `loaded page excludes already refreshed and drops empty result`() { + val ids = listOf(1L, 2L, 3L) + val chunks = ReactionRefreshPlanner.forLoadedPage( + ids, + sourcedFromServer = false, + alreadyRefreshed = setOf(1L, 2L, 3L), + ) + assertEquals(emptyList>(), chunks) + } + + @Test + fun `loaded page empty input yields no chunks`() { + val chunks = ReactionRefreshPlanner.forLoadedPage(emptyList(), sourcedFromServer = false) + assertEquals(emptyList>(), chunks) + } +} From 9c28dcdef34d41bf602caf0d7d8b0fbc38e7e264 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 16:35:07 -0400 Subject: [PATCH 16/58] feat(chat): add ReactionPicker/Reactors ChatStep and RecentReactionsStore.stats --- .../com/flipcash/app/core/chat/ChatStep.kt | 18 ++++++++++++++++++ .../emojis/reactions/RecentReactionsStore.kt | 11 ++++++++--- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt index af9adcc605..51667a45a7 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt @@ -96,4 +96,22 @@ sealed interface ChatStep : FlowStep, Parcelable { @Parcelize @Serializable data object EditGroupPicture : ChatStep + + /** + * The full emoji picker for a message's reaction, opened from the pill row's "+" or the + * quick strip's "+". A plain [Sheet] rather than [WrapContentSheet]: the picker's catalog + * needs the screen's height, unlike the short, fixed-content sheets above. + */ + @Parcelize + @Serializable + data class ReactionPicker(val messageId: Long) : ChatStep, Sheet + + /** + * Who reacted, and with what — opened by long-pressing a pill. A plain [Sheet] for the same + * reason as [ReactionPicker]: a per-emoji reactor list can run long enough to need the full + * sheet height rather than wrapping its content. + */ + @Parcelize + @Serializable + data class Reactors(val messageId: Long) : ChatStep, Sheet } diff --git a/libs/emojis/src/main/kotlin/com/getcode/libs/emojis/reactions/RecentReactionsStore.kt b/libs/emojis/src/main/kotlin/com/getcode/libs/emojis/reactions/RecentReactionsStore.kt index 4b524cdd57..f43ca1d082 100644 --- a/libs/emojis/src/main/kotlin/com/getcode/libs/emojis/reactions/RecentReactionsStore.kt +++ b/libs/emojis/src/main/kotlin/com/getcode/libs/emojis/reactions/RecentReactionsStore.kt @@ -23,6 +23,9 @@ interface RecentReactionsStore { /** Returns up to [limit] emoji in rank order, leaving out [undrawable]. */ suspend fun rank(undrawable: Set = emptySet(), limit: Int = RecentReactions.DEFAULTS.size): List + + /** The raw per-emoji use stats, for a caller (e.g. [com.flipcash.shared.chat.reactions.ReactionStripComposer]) that ranks them itself. */ + suspend fun stats(): Map } @Singleton @@ -41,15 +44,17 @@ class DataStoreRecentReactionsStore @Inject constructor( } } - override suspend fun rank(undrawable: Set, limit: Int): List { + override suspend fun rank(undrawable: Set, limit: Int): List = + RecentReactions.rank(stats(), undrawable, limit) + + override suspend fun stats(): Map { val prefs = dataStore.data.first() - val stats = prefs.asMap().mapNotNull { (prefsKey, raw) -> + return prefs.asMap().mapNotNull { (prefsKey, raw) -> val emoji = prefsKey.name.removePrefix(KEY_PREFIX) if (emoji == prefsKey.name) return@mapNotNull null // not one of ours val usage = decode(raw as? String) ?: return@mapNotNull null emoji to usage }.toMap() - return RecentReactions.rank(stats, undrawable, limit) } private fun key(emoji: String) = stringPreferencesKey("$KEY_PREFIX$emoji") From 0cd0ca2bc061fe4581ed4ec51e201d28d2c06bbc Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 16:36:30 -0400 Subject: [PATCH 17/58] feat(chat-ui): add reactionPills and canReact to ContentBubble --- .../com/flipcash/shared/chat/models/ChatListItem.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt index dc8bef481d..915b93574a 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt @@ -2,6 +2,7 @@ package com.flipcash.shared.chat.models import com.flipcash.services.models.chat.MessageContent import com.flipcash.shared.chat.MessageCapability +import com.flipcash.shared.chat.reactions.ReactionPill import com.getcode.opencode.model.core.ID import kotlin.time.Instant @@ -96,6 +97,17 @@ sealed interface ChatListItem { * the receipt go. */ val isLastRow: Boolean = true, + /** + * This message's reaction pills, in display order — the live overlay when one exists, + * otherwise `MessageReactions.from` applied to the message's own stored summary. Empty + * for a message with no reactions. + */ + val reactionPills: List = emptyList(), + /** + * Whether the viewer may react to this message — system messages and messages still + * pending send are excluded. See `com.flipcash.shared.chat.canReact`. + */ + val canReact: Boolean = false, ) : ChatListItem { /** * Who this bubble is attributed to, for grouping. [senderId] is the answer whenever the From 98b0ea52a0cc5949e49b089fb860d32f6f086e4c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 16:43:31 -0400 Subject: [PATCH 18/58] feat(chat): add ReactionPicker/Reactors nav stubs and ToggleReaction actions --- .../core/src/main/res/values/strings.xml | 4 ++ .../flipcash/app/messenger/ChatFlowScreen.kt | 38 +++++++++++++++++++ .../flipcash/shared/chat/models/ChatAction.kt | 12 ++++++ 3 files changed, 54 insertions(+) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index c981d0eff9..8d493f5495 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1004,6 +1004,10 @@ Message Not Deleted The message couldn\'t be deleted, so it is still visible to everyone. Message + Couldn\'t add reaction + Please try again. + This message has the maximum number of reactions + Remove one to add another. Unknown Contact Allow Full Contact Access diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index 621c64f6af..a48a754e1c 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -1,6 +1,9 @@ package com.flipcash.app.messenger import android.os.Parcelable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -8,6 +11,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavKey @@ -116,6 +121,15 @@ private fun chatEntryProvider( annotatedEntry { FlowEditGroupPictureScreen() } + + // Placeholder entries: content lands in a follow-up. Registering the step now is what lets + // ChatViewModel's OpenReactionPicker/OpenReactors dispatch a real navigation. + annotatedEntry { + FlowReactionPickerScreen() + } + annotatedEntry { + FlowReactorsScreen() + } } @Composable @@ -318,3 +332,27 @@ private fun FlowEditGroupNameScreen() { private fun FlowEditGroupPictureScreen() { EditGroupPictureScreen(flowSharedViewModel()) } + +/** + * Placeholder for the full emoji picker sheet — content is a separate change. This exists only so + * [ChatStep.ReactionPicker] is a real, navigable destination for + * `ChatViewModel.Event.OpenReactionPicker` to reach; [flowSharedViewModel] keeps it on the same + * conversation view model the picker will read from once it has content. + */ +@Composable +private fun FlowReactionPickerScreen() { + flowSharedViewModel() + Box(modifier = Modifier.fillMaxWidth().height(1.dp)) +} + +/** + * Placeholder for the reactors sheet — content is a separate change. This exists only so + * [ChatStep.Reactors] is a real, navigable destination for `ChatViewModel.Event.OpenReactors` to + * reach; [flowSharedViewModel] keeps it on the same conversation view model the sheet will read + * from once it has content. + */ +@Composable +private fun FlowReactorsScreen() { + flowSharedViewModel() + Box(modifier = Modifier.fillMaxWidth().height(1.dp)) +} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt index 79f76cef51..b6f8a79c4c 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt @@ -84,6 +84,18 @@ sealed interface ChatAction { * that boundary — a claim arrives back knowing an entropy and nothing about a transcript. */ data class CashLinkOpened(val entropy: String, val url: String) : ChatAction + + /** + * Toggles the viewer's reaction with [emoji] on [messageId] — a tap on a pill, on a quick-strip + * entry, or a pick from the full picker all resolve to this one action. + */ + data class ToggleReaction(val messageId: Long, val emoji: String) : ChatAction + + /** Opens the full emoji picker for [messageId], from the pill row's or the quick strip's "+". */ + data class OpenReactionPicker(val messageId: Long) : ChatAction + + /** Opens who-reacted for [messageId], from a long-press on a pill. */ + data class OpenReactors(val messageId: Long) : ChatAction } typealias ChatActionHandler = (ChatAction) -> Unit From 09c403af077fec8b6dc6eb3c7cda37bb4652a34a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 16:47:57 -0400 Subject: [PATCH 19/58] feat(chat): wire reaction pills into mappedMessages and ToggleReaction/picker/reactors events --- .../app/messenger/internal/ChatViewModel.kt | 96 +++++++++++++++++++ .../internal/screens/MessengerScreen.kt | 20 ++++ .../flipcash/shared/chat/models/ChatAction.kt | 9 +- 3 files changed, 123 insertions(+), 2 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 22e41d114b..9f2c0637c9 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -13,6 +13,7 @@ import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn import androidx.paging.flatMap +import androidx.paging.map import com.flipcash.analytics.AddMoneySource import com.flipcash.analytics.GroupAccess as AnalyticsGroupAccess import com.flipcash.analytics.GroupGateFunding @@ -86,6 +87,9 @@ import com.flipcash.shared.chat.models.splitAroundLinkCard import com.flipcash.shared.chat.models.SenderIdentity import com.flipcash.shared.chat.models.SeparatorConfig import com.flipcash.shared.chat.readOnly +import com.flipcash.shared.chat.MessageReactions +import com.flipcash.shared.chat.reactions.ReactionError +import com.flipcash.shared.chat.canReact import com.flipcash.shared.chat.resolveCapabilities import com.flipcash.shared.chat.ui.detectUrls import com.flipcash.shared.chat.ui.linkableText @@ -587,6 +591,24 @@ internal class ChatViewModel @Inject constructor( data class ReplyToMessage(val quote: ChatQuote) : Event data object CancelReply : Event + /** + * Toggles the viewer's reaction with [emoji] on [messageId] — a tap on a pill, on the + * quick strip above a selected bubble, or a pick from the full picker. When it came from + * the strip, [clearsSelection] takes the bubble out of selection mode the same tap it acts + * on; a tap on the pill row under a bubble that isn't selected leaves selection untouched. + */ + data class ToggleReaction( + val messageId: Long, + val emoji: String, + val clearsSelection: Boolean = false, + ) : Event + + /** Opens the full emoji picker for [messageId], from a pill row's or the strip's "+". */ + data class OpenReactionPicker(val messageId: Long) : Event + + /** Opens who-reacted for [messageId], from a long-press on a pill. */ + data class OpenReactors(val messageId: Long) : Event + /** * The reader tapped a cash voucher in this transcript, naming the link. * @@ -646,6 +668,16 @@ internal class ChatViewModel @Inject constructor( */ private val viewerCanPost = stateFlow.map { !it.isOutsideGroup }.distinctUntilChanged() + /** + * Live reaction overrides for the open chat — see [ReactionOperations.observeChatReactions]. + * A message missing here falls back to `MessageReactions.from(message.reactions)` in + * [mappedMessages], per that function's contract. + */ + @OptIn(ExperimentalCoroutinesApi::class) + private val reactionOverlay: Flow> = stateFlow.mapNotNull { it.chatId } + .distinctUntilChanged() + .flatMapLatest { chatCoordinator.observeChatReactions(it) } + /** * Null for a DM, a map for a group — the distinction the transcript needs, because a DM's * counterparty is already named in the title bar and attributing each of their bubbles would be @@ -802,6 +834,11 @@ internal class ChatViewModel @Inject constructor( // after the first page does. senderId = message.senderId?.takeIf { !message.isFromSelf }, linkCard = linkCard, + // Stored fallback only — see the reactionOverlay pass below, which fills + // in a live override for whichever row ends up as `isLastRow` once + // `splitAroundLinkCard` has run. + reactionPills = MessageReactions.from(message.reactions).pills, + canReact = canReact(message), ) // A carded link takes a row of its own, with the prose either side of it // on rows above and below. Reversed because the list is: this page runs @@ -810,6 +847,23 @@ internal class ChatViewModel @Inject constructor( .asReversed() } } + } + // A live reaction override — an in-flight tap, or a refreshed/streamed confirmed state — + // beats the stored fallback baked in above. Combined as its own pass over the already-built + // pages rather than folded into the combine above: it needs nothing else that combine reads + // (only the message id already on each row), and keeping it separate means a reaction + // update re-maps pills without re-running sender resolution, link classification or the + // policy/capability pass over every row. + .let { pages -> + combine(pages, reactionOverlay) { data, overlay -> + if (overlay.isEmpty()) return@combine data + data.map { bubble -> + // Only the row the "Edited" marker also lands on carries the pill row, so a + // split message shows one set of pills, not one per row. + val live = if (bubble.isLastRow) overlay[bubble.messageId] else null + if (live == null) bubble else bubble.copy(reactionPills = live.pills) + } + } }.cachedIn(viewModelScope) /** @@ -1643,6 +1697,40 @@ internal class ChatViewModel @Inject constructor( } private fun initMessageActionHandlers() { + eventFlow.filterIsInstance() + .onEach { event -> + val chatId = stateFlow.value.chatId ?: return@onEach + viewModelScope.launch { chatCoordinator.toggleReaction(chatId, event.messageId, event.emoji) } + } + .launchIn(viewModelScope) + + // OpenReactionPicker/OpenReactors carry no side effect here — the screen's action handler + // pushes the ChatStep, the same as every other transcript-triggered navigation (see + // MessengerScreen's chatActionHandler). The reducer above is a no-op for both too; they + // exist as events at all only so a picker pick, once B2 adds it, can reach ToggleReaction + // through the same dispatch path everything else in the transcript uses. + + // A failed add/remove the coordinator has already rolled back optimistically — this only + // tells the reader why the pill snapped back. + chatCoordinator.reactionErrors + .onEach { error -> + BottomBarManager.showError( + title = resources.getString( + when (error) { + ReactionError.REACTION_FAILED -> R.string.title_reactionNotAdded + ReactionError.TOO_MANY_REACTION_TYPES -> R.string.title_reactionLimitReached + } + ), + message = resources.getString( + when (error) { + ReactionError.REACTION_FAILED -> R.string.description_reactionNotAdded + ReactionError.TOO_MANY_REACTION_TYPES -> R.string.description_reactionLimitReached + } + ), + ) + } + .launchIn(viewModelScope) + // Read off the state rather than carried on the event, so the row that copies the link and // the row that shares it are handing out the one url [State.groupInviteUrl] builds. eventFlow.filterIsInstance() @@ -2505,6 +2593,14 @@ internal class ChatViewModel @Inject constructor( ) } Event.CancelReply -> { state -> state.copy(replyingTo = null) } + // The toggle itself is handled in initMessageActionHandlers (it calls the + // coordinator); the reducer's only job is to take the bubble out of selection mode + // when the tap came from the quick strip. + is Event.ToggleReaction -> { state -> + if (event.clearsSelection) state.copy(selection = null) else state + } + is Event.OpenReactionPicker -> { state -> state } + is Event.OpenReactors -> { state -> state } // Nothing on screen moves when a voucher is tapped -- the link leaves, the card // keeps saying what it said, and the claim comes back as its own signal. is Event.CashLinkOpened -> { state -> state } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index 8c1b733d24..8bc3953a39 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -196,6 +196,26 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { keyboard.hideIfVisible { navigator.push(ChatStep.Profile(it)) } } } + + is ChatAction.ToggleReaction -> { + viewModel.dispatchEvent( + ChatViewModel.Event.ToggleReaction( + messageId = action.messageId, + emoji = action.emoji, + clearsSelection = action.fromStrip, + ) + ) + } + + is ChatAction.OpenReactionPicker -> { + viewModel.dispatchEvent(ChatViewModel.Event.OpenReactionPicker(action.messageId)) + navigator.push(ChatStep.ReactionPicker(action.messageId)) + } + + is ChatAction.OpenReactors -> { + viewModel.dispatchEvent(ChatViewModel.Event.OpenReactors(action.messageId)) + navigator.push(ChatStep.Reactors(action.messageId)) + } } Unit diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt index b6f8a79c4c..faaa40e78c 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt @@ -87,9 +87,14 @@ sealed interface ChatAction { /** * Toggles the viewer's reaction with [emoji] on [messageId] — a tap on a pill, on a quick-strip - * entry, or a pick from the full picker all resolve to this one action. + * entry, or a pick from the full picker all resolve to this one action. [fromStrip] is true + * only for the quick strip above a selected bubble, whose tap also exits selection mode. */ - data class ToggleReaction(val messageId: Long, val emoji: String) : ChatAction + data class ToggleReaction( + val messageId: Long, + val emoji: String, + val fromStrip: Boolean = false, + ) : ChatAction /** Opens the full emoji picker for [messageId], from the pill row's or the quick strip's "+". */ data class OpenReactionPicker(val messageId: Long) : ChatAction From c4625d4b5c5e04ffc92b07d3fe8fc6839534d368 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 16:48:37 -0400 Subject: [PATCH 20/58] test(chat): cover ToggleReaction's selection-clearing reducer branch --- .../internal/ChatMessageActionReducerTest.kt | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionReducerTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionReducerTest.kt index 8070348935..8bc3cc2b72 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionReducerTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionReducerTest.kt @@ -369,4 +369,40 @@ class ChatMessageActionReducerTest { assertNull(state.jumpTarget) assertNull(state.jumpBudget) } + + // ToggleReaction — the reducer itself does nothing to the reaction (that's the coordinator's + // job, run from initMessageActionHandlers); it only decides whether the tap that fired it also + // closes the selection bar, per Event.ToggleReaction.clearsSelection. + + @Test + fun `toggling a reaction from the pill row leaves the selection alone`() { + val target = bubble(1) + val selected = reduce( + ChatViewModel.State(messagePolicy = unbounded), + ChatViewModel.Event.ToggleMessageSelection(target), + ) + + val state = reduce( + selected, + ChatViewModel.Event.ToggleReaction(messageId = 1, emoji = "🔥", clearsSelection = false), + ) + + assertEquals(target, state.selection) + } + + @Test + fun `toggling a reaction from the quick strip clears the selection`() { + val target = bubble(1) + val selected = reduce( + ChatViewModel.State(messagePolicy = unbounded), + ChatViewModel.Event.ToggleMessageSelection(target), + ) + + val state = reduce( + selected, + ChatViewModel.Event.ToggleReaction(messageId = 1, emoji = "🔥", clearsSelection = true), + ) + + assertNull(state.selection) + } } From 9ab590fac7a1f66f0310c6f9fefcf61ec10d6a4f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 16:59:19 -0400 Subject: [PATCH 21/58] feat(chat): wire reaction pills, quick strip, and picker/reactors navigation into ChatViewModel - Attach per-message reaction pills and canReact to ContentBubble in mappedMessages, sourced from the live reaction overlay with Room fallback via MessageReactions.from. - Add ToggleReaction/OpenReactionPicker/OpenReactors events, wired through ChatCoordinator.toggleReaction and the screen's navigation to the ChatStep.ReactionPicker/Reactors stubs. - Surface reactionErrors as a snackbar via BottomBarManager.showError. - Compose the quick strip's entries from RecentReactionsStore + EmojiCatalog + EmojiDrawability whenever the selected, reactable message changes; record a used emoji on add (not on remove). - Render ReactionPillRow under each bubble and QuickReactionStrip above a selected, reactable bubble in MessageRow/MessageList. --- .../features/messenger/build.gradle.kts | 1 + .../app/messenger/internal/ChatViewModel.kt | 72 ++++++++++++++++++- .../screens/components/MessageList.kt | 5 ++ .../internal/screens/components/MessageRow.kt | 59 +++++++++++++++ .../internal/ChatGroupAnalyticsTest.kt | 6 ++ .../internal/ChatSendFailureAnalyticsTest.kt | 6 ++ .../shared/chat/ui/ReactionPillRow.kt | 63 ++++++++++++++++ 7 files changed, 210 insertions(+), 2 deletions(-) diff --git a/apps/flipcash/features/messenger/build.gradle.kts b/apps/flipcash/features/messenger/build.gradle.kts index 4c59fcef43..0395c8a439 100644 --- a/apps/flipcash/features/messenger/build.gradle.kts +++ b/apps/flipcash/features/messenger/build.gradle.kts @@ -18,6 +18,7 @@ dependencies { implementation(project(":apps:flipcash:shared:contacts")) implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":apps:flipcash:shared:funding")) + implementation(project(":libs:emojis")) implementation(project(":apps:flipcash:shared:menu")) implementation(project(":apps:flipcash:shared:payments")) implementation(project(":apps:flipcash:shared:router")) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 9f2c0637c9..8ad77e83dc 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -89,6 +89,12 @@ import com.flipcash.shared.chat.models.SeparatorConfig import com.flipcash.shared.chat.readOnly import com.flipcash.shared.chat.MessageReactions import com.flipcash.shared.chat.reactions.ReactionError +import com.flipcash.shared.chat.reactions.ReactionStrip +import com.flipcash.shared.chat.reactions.ReactionStripComposer +import com.flipcash.shared.chat.reactions.SelfReaction +import com.getcode.libs.emojis.reactions.EmojiCatalogLoader +import com.getcode.libs.emojis.reactions.EmojiDrawability +import com.getcode.libs.emojis.reactions.RecentReactionsStore import com.flipcash.shared.chat.canReact import com.flipcash.shared.chat.resolveCapabilities import com.flipcash.shared.chat.ui.detectUrls @@ -182,6 +188,8 @@ internal class ChatViewModel @Inject constructor( private val linkCardResolver: LinkCardResolver, private val cashLinkClaims: CashLinkClaims, private val chatDraftStore: ChatDraftStore, + private val recentReactionsStore: RecentReactionsStore, + private val emojiCatalogLoader: EmojiCatalogLoader, dispatchers: DispatcherProvider, ) : BaseViewModel( initialState = State(), @@ -327,6 +335,12 @@ internal class ChatViewModel @Inject constructor( * the whole round trip and read as dead — which is what it looked like before it had one. */ val joinProgress: LoadingSuccessState = LoadingSuccessState(), + /** + * The quick strip's entries for [selection], recomposed whenever the selected message + * changes — empty while nothing is selected, while the selected message can't be reacted + * to, or before the catalog/recents have loaded for the first selection of this session. + */ + val quickReactionStrip: List = emptyList(), ) { /** * The DM counterparty, or `null` for a group. @@ -609,6 +623,9 @@ internal class ChatViewModel @Inject constructor( /** Opens who-reacted for [messageId], from a long-press on a pill. */ data class OpenReactors(val messageId: Long) : Event + /** Internal: the quick strip's entries for the current selection have (re)composed. */ + data class QuickReactionStripComposed(val entries: List) : Event + /** * The reader tapped a cash voucher in this transcript, naming the link. * @@ -672,11 +689,17 @@ internal class ChatViewModel @Inject constructor( * Live reaction overrides for the open chat — see [ReactionOperations.observeChatReactions]. * A message missing here falls back to `MessageReactions.from(message.reactions)` in * [mappedMessages], per that function's contract. + * + * Held as a [StateFlow] (Eagerly, like [senderProfiles]) rather than left cold: the toggle + * handler needs a synchronous read of "is this already self-reacted" to decide whether a tap + * is an add worth ranking for the quick strip, and a cold flow would mean starting a second + * collection just to answer that one question. */ @OptIn(ExperimentalCoroutinesApi::class) - private val reactionOverlay: Flow> = stateFlow.mapNotNull { it.chatId } + private val reactionOverlay: StateFlow> = stateFlow.mapNotNull { it.chatId } .distinctUntilChanged() .flatMapLatest { chatCoordinator.observeChatReactions(it) } + .stateIn(viewModelScope, SharingStarted.Eagerly, emptyMap()) /** * Null for a DM, a map for a group — the distinction the transcript needs, because a DM's @@ -1700,7 +1723,51 @@ internal class ChatViewModel @Inject constructor( eventFlow.filterIsInstance() .onEach { event -> val chatId = stateFlow.value.chatId ?: return@onEach - viewModelScope.launch { chatCoordinator.toggleReaction(chatId, event.messageId, event.emoji) } + viewModelScope.launch { + // Only an add is worth ranking for the quick strip's "most used" slots — a + // remove is the reader taking it back, not a preference to weight. The live + // overlay is what's already reacted as far as this session knows; a message + // with no override yet has no self-reactions to take back, so it's an add. + val alreadyReacted = reactionOverlay.value[event.messageId] + ?.selfReactions?.any { it.emoji == event.emoji } == true + if (!alreadyReacted) recentReactionsStore.record(event.emoji) + chatCoordinator.toggleReaction(chatId, event.messageId, event.emoji) + } + } + .launchIn(viewModelScope) + + // The quick strip above a selected, reactable bubble — recomposed each time the selection + // moves to a different message. Reads the live overlay for self-reactions rather than the + // selected bubble's own (possibly stale) snapshot, the same source [mappedMessages] treats + // as authoritative. + stateFlow.map { it.selection?.takeIf { bubble -> bubble.canReact }?.messageId } + .distinctUntilChanged() + .flatMapLatest { messageId -> + if (messageId == null) { + flowOf(emptyList()) + } else { + reactionOverlay.map { overlay -> + overlay[messageId]?.selfReactions.orEmpty() + }.distinctUntilChanged() + } + } + .onEach { selfReactions -> + val entries = if (selfReactions.isEmpty() && stateFlow.value.selection == null) { + emptyList() + } else { + val catalog = emojiCatalogLoader.load() + val undrawable = catalog.firstCategoryEntries + .map { it.emoji } + .filterNot { EmojiDrawability.isDrawable(it) } + .toSet() + ReactionStripComposer.compose( + recentStats = recentReactionsStore.stats(), + selfReactions = selfReactions, + catalogFillSource = catalog.firstCategoryEntries.map { it.emoji }, + undrawable = undrawable, + ) + } + dispatchEvent(Event.QuickReactionStripComposed(entries)) } .launchIn(viewModelScope) @@ -2601,6 +2668,7 @@ internal class ChatViewModel @Inject constructor( } is Event.OpenReactionPicker -> { state -> state } is Event.OpenReactors -> { state -> state } + is Event.QuickReactionStripComposed -> { state -> state.copy(quickReactionStrip = event.entries) } // Nothing on screen moves when a voucher is tapped -- the link leaves, the card // keeps saying what it said, and the claim comes back as its own signal. is Event.CashLinkOpened -> { state -> state } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index a8ecbbd21f..40a1f6372e 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -306,6 +306,11 @@ internal fun MessageList( focused = focused, animateInsertion = animateInsertion, showsSenderGutter = isGroup, + quickReactionStrip = if (focused && bubble?.messageId == state.selection?.messageId) { + state.quickReactionStrip + } else { + emptyList() + }, attention = if (bubble != null && bubble.messageId == attentionId) { readAttention } else { diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt index 9a9fdb797c..9b52881980 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -20,6 +21,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Reply @@ -53,7 +55,10 @@ import com.flipcash.shared.chat.models.LocalChatActionHandler import com.flipcash.shared.chat.models.ReceiptStatus import com.flipcash.shared.chat.models.SeparatorConfig import com.flipcash.features.messenger.R +import com.flipcash.shared.chat.reactions.ReactionStrip import com.flipcash.shared.chat.ui.ContentBubble +import com.flipcash.shared.chat.ui.QuickReactionStrip +import com.flipcash.shared.chat.ui.ReactionPillRow import com.flipcash.shared.chat.ui.rendersBare import com.flipcash.shared.common.ui.ContactAvatar import com.flipcash.shared.chat.ui.bubblePositionOf @@ -86,6 +91,7 @@ internal fun MessageRow( focused: Boolean, animateInsertion: Boolean, showsSenderGutter: Boolean, + quickReactionStrip: List = emptyList(), attention: () -> Float = { 0f }, ) { val onAction = LocalChatActionHandler.current @@ -311,6 +317,53 @@ internal fun MessageRow( ), attention = attention, ) + + // The quick strip lives above the bubble, only while this exact + // message is the selected one — it replaces the backdrop's own + // reach for a reaction with something faster than opening the + // picker, and disappears the moment selection moves off. + if (selecting && focused && item.canReact && quickReactionStrip.isNotEmpty()) { + QuickReactionStrip( + entries = quickReactionStrip, + onToggle = { emoji -> + onAction( + ChatAction.ToggleReaction( + messageId = item.messageId, + emoji = emoji, + fromStrip = true, + ) + ) + }, + onOpenPicker = { onAction(ChatAction.OpenReactionPicker(item.messageId)) }, + modifier = Modifier + .align(if (item.isFromSelf) Alignment.TopEnd else Alignment.TopStart) + .offset(y = -QUICK_STRIP_OFFSET), + ) + } + } + // Same width as the bubble above it (decision 2) — matched here + // against the same fraction MessageBubble sizes a text/reply/deleted + // bubble to, since the row doesn't expose its resolved width outward. + if (!selecting && (item.reactionPills.isNotEmpty() || item.canReact)) { + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + ReactionPillRow( + pills = item.reactionPills, + canReact = item.canReact, + onToggle = { emoji -> + onAction(ChatAction.ToggleReaction(item.messageId, emoji)) + }, + onPillLongClick = { + onAction(ChatAction.OpenReactors(item.messageId)) + }, + onOpenPicker = { + onAction(ChatAction.OpenReactionPicker(item.messageId)) + }, + modifier = Modifier + .align(if (item.isFromSelf) Alignment.TopEnd else Alignment.TopStart) + .width(maxWidth * BUBBLE_ROW_WIDTH_FRACTION), + alignEnd = item.isFromSelf, + ) + } } val showReceipt = shouldShowReceiptLabel(index, item, messages, otherReadPointer) @@ -427,6 +480,12 @@ private fun senderNameInset(showsGutter: Boolean): Dp = CodeTheme.dimens.grid.x1 } +// Matches MessageBubble's own BUBBLE_MAX_WIDTH_FRACTION for a text/reply/deleted bubble — the row +// doesn't expose its resolved width outward, so the pill row underneath it re-derives the same +// fraction of the shared row width instead. +private const val BUBBLE_ROW_WIDTH_FRACTION = 0.78f +private val QUICK_STRIP_OFFSET = 12.dp + private val AFFORDANCE_SIZE = 32.dp private val AFFORDANCE_INSET = 20.dp private val AFFORDANCE_ICON_INSET = 8.dp diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt index 204e527d77..6362fec0d5 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt @@ -29,6 +29,8 @@ import com.flipcash.shared.chat.ChatMembership import com.flipcash.shared.chat.GroupAccess import com.flipcash.shared.payments.ContactPaymentDelegate import com.flipcash.shared.payments.TipPaymentDelegate +import com.getcode.libs.emojis.reactions.EmojiCatalogLoader +import com.getcode.libs.emojis.reactions.RecentReactionsStore import com.getcode.manager.BottomBarManager import com.getcode.opencode.controllers.TransactionController import com.getcode.opencode.exchange.Exchange @@ -92,6 +94,8 @@ class ChatGroupAnalyticsTest { private val linkCardResolver = mockk(relaxed = true) private val cashLinkClaims = mockk(relaxed = true) private val chatDraftStore = mockk(relaxed = true) + private val recentReactionsStore = mockk(relaxed = true) + private val emojiCatalogLoader = mockk(relaxed = true) private val chatId = ChatId(UUID.randomUUID().bytes) private val mint = Mint("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaaaaaaaaaaa") @@ -141,6 +145,8 @@ class ChatGroupAnalyticsTest { linkCardResolver = linkCardResolver, cashLinkClaims = cashLinkClaims, chatDraftStore = chatDraftStore, + recentReactionsStore = recentReactionsStore, + emojiCatalogLoader = emojiCatalogLoader, dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), ) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt index 8c63e3cace..6a589f44cd 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt @@ -23,6 +23,8 @@ import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.shared.chat.ChatDraftStore import com.flipcash.shared.payments.ContactPaymentDelegate import com.flipcash.shared.payments.TipPaymentDelegate +import com.getcode.libs.emojis.reactions.EmojiCatalogLoader +import com.getcode.libs.emojis.reactions.RecentReactionsStore import com.getcode.manager.BottomBarManager import com.getcode.opencode.controllers.TransactionController import com.getcode.opencode.exchange.Exchange @@ -87,6 +89,8 @@ class ChatSendFailureAnalyticsTest { private val linkCardResolver = mockk(relaxed = true) private val cashLinkClaims = mockk(relaxed = true) private val chatDraftStore = mockk(relaxed = true) + private val recentReactionsStore = mockk(relaxed = true) + private val emojiCatalogLoader = mockk(relaxed = true) private val token = mockk(relaxed = true) private val amount = Fiat(5.0) @@ -133,6 +137,8 @@ class ChatSendFailureAnalyticsTest { linkCardResolver = linkCardResolver, cashLinkClaims = cashLinkClaims, chatDraftStore = chatDraftStore, + recentReactionsStore = recentReactionsStore, + emojiCatalogLoader = emojiCatalogLoader, dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), ) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionPillRow.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionPillRow.kt index b5f1e47543..6c10b3118f 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionPillRow.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionPillRow.kt @@ -3,8 +3,14 @@ package com.flipcash.shared.chat.ui import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -22,6 +28,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.flipcash.shared.chat.reactions.ReactionPill +import com.flipcash.shared.chat.reactions.ReactionStrip import com.getcode.theme.CodeTheme /** @@ -256,6 +263,62 @@ fun ReactionPillRow( } } +/** + * The strip a long-press on a reactable, selected bubble shows above it (decision 5): up to the 12 + * entries [ReactionStripComposer][com.flipcash.shared.chat.reactions.ReactionStripComposer] + * composed, self-reacted ones highlighted, plus a trailing "+" that opens the full picker. A tap + * on an entry both toggles the reaction and clears the selection — [onToggle] is the one signal + * for both, same as the pill row's tap is a plain toggle without one. + * + * Horizontally scrolling rather than wrapping: it draws above a single bubble, where wrapping to a + * second line would push it further from the message it's reacting to. + */ +@Composable +fun QuickReactionStrip( + entries: List, + onToggle: (emoji: String) -> Unit, + onOpenPicker: () -> Unit, + modifier: Modifier = Modifier, +) { + if (entries.isEmpty()) return + + Row( + modifier = modifier + .background(color = Color.Black.copy(alpha = 0.72f), shape = RoundedCornerShape(percent = 50)) + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 6.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + entries.forEach { entry -> + Box( + modifier = Modifier + .size(32.dp) + .let { base -> + if (entry.highlighted) { + base.background(Color.White.copy(alpha = 0.16f), CircleShape) + } else { + base + } + } + .combinedClickable(onClick = { onToggle(entry.emoji) }, onLongClick = {}), + contentAlignment = Alignment.Center, + ) { + Text(text = entry.emoji, fontSize = 20.sp) + } + } + Box( + modifier = Modifier + .size(32.dp) + .background(Color.White.copy(alpha = 0.06f), CircleShape) + .combinedClickable(onClick = onOpenPicker, onLongClick = {}), + contentAlignment = Alignment.Center, + ) { + Text(text = "+", fontSize = 18.sp) + } + } +} + @Composable private fun ReactionPillChip( pill: ReactionPill, From 597a776baf9b0fd147c76a8ba7af7141d419c4fe Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 17:05:33 -0400 Subject: [PATCH 22/58] feat(chat): wire reaction refresh hooks into the transcript's paging lifecycle - Add ChatAction.RefreshReactionIds and ChatViewModel.Event.RefreshReactionIds, forwarding batches to ChatCoordinator.refreshReactions. - In MessageList, refresh the newest 60 loaded ids on open and on every screen-lifecycle resume (repeatOnLifecycle(RESUMED), mirroring ChatViewModel's own repeatOnLifecycle(STARTED) pattern), and refresh Room-sourced pages in ~40-id chunks as they page in, skipping pages the RemoteMediator just fetched (loadState.mediator?.append is Loading). --- .../app/messenger/internal/ChatViewModel.kt | 20 ++++++ .../internal/screens/MessengerScreen.kt | 4 ++ .../screens/components/MessageList.kt | 61 +++++++++++++++++++ .../flipcash/shared/chat/models/ChatAction.kt | 8 +++ 4 files changed, 93 insertions(+) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 8ad77e83dc..9bc19a7dab 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -626,6 +626,13 @@ internal class ChatViewModel @Inject constructor( /** Internal: the quick strip's entries for the current selection have (re)composed. */ data class QuickReactionStripComposed(val entries: List) : Event + /** + * The transcript's own refresh-on-paging hook has ids it wants reaction state refreshed + * for — see [ReactionRefreshPlanner] and the [ChatAction.RefreshReactionIds] that carries + * this down from `MessageList`. Not a reader gesture, so it changes no visible state. + */ + data class RefreshReactionIds(val messageIds: List) : Event + /** * The reader tapped a cash voucher in this transcript, naming the link. * @@ -1777,6 +1784,18 @@ internal class ChatViewModel @Inject constructor( // exist as events at all only so a picker pick, once B2 adds it, can reach ToggleReaction // through the same dispatch path everything else in the transcript uses. + // See ReactionRefreshPlanner and MessageList's reaction-refresh effects: the transcript + // computes which ids need a refresh (the newest window on open/resume, Room-sourced pages + // as they page in) and hands the batch down here — this just forwards it to the + // coordinator. No reducer case: nothing about visible state changes from this. + eventFlow.filterIsInstance() + .onEach { event -> + val chatId = stateFlow.value.chatId ?: return@onEach + if (event.messageIds.isEmpty()) return@onEach + chatCoordinator.refreshReactions(chatId, event.messageIds) + } + .launchIn(viewModelScope) + // A failed add/remove the coordinator has already rolled back optimistically — this only // tells the reader why the pill snapped back. chatCoordinator.reactionErrors @@ -2669,6 +2688,7 @@ internal class ChatViewModel @Inject constructor( is Event.OpenReactionPicker -> { state -> state } is Event.OpenReactors -> { state -> state } is Event.QuickReactionStripComposed -> { state -> state.copy(quickReactionStrip = event.entries) } + is Event.RefreshReactionIds -> { state -> state } // Nothing on screen moves when a voucher is tapped -- the link leaves, the card // keeps saying what it said, and the claim comes back as its own signal. is Event.CashLinkOpened -> { state -> state } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index 8bc3953a39..d690356843 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -216,6 +216,10 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { viewModel.dispatchEvent(ChatViewModel.Event.OpenReactors(action.messageId)) navigator.push(ChatStep.Reactors(action.messageId)) } + + is ChatAction.RefreshReactionIds -> { + viewModel.dispatchEvent(ChatViewModel.Event.RefreshReactionIds(action.messageIds)) + } } Unit diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index 40a1f6372e..7b7e5da032 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -33,6 +33,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle import androidx.paging.LoadState import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.itemKey @@ -49,6 +52,7 @@ import com.flipcash.shared.chat.models.LinkCardResolution import com.flipcash.shared.chat.models.LocalChatActionHandler import com.flipcash.shared.chat.models.LocalLinkCardResolution import com.flipcash.shared.chat.models.SeparatorConfig +import com.flipcash.shared.chat.reactions.ReactionRefreshPlanner import com.getcode.theme.CodeTheme import com.getcode.ui.theme.CodeButton import com.getcode.ui.utils.rememberKeyboardController @@ -234,6 +238,55 @@ internal fun MessageList( refreshSettled = true } + // Reaction refresh (decision 6, see ReactionRefreshPlanner): a live overlay override only + // exists for a chat this device is actively watching, so a reaction added while the app was + // backgrounded — or before this chat was ever opened — has to be asked for explicitly. + // + // Two hooks, matching the two ways a message's reaction state can go stale here: + // + // 1. The newest window, on open and every resume. Modeled on ChatViewModel's own + // repeatOnLifecycle(STARTED) pattern (see initLinkCardFreshness) but anchored to this + // screen's own lifecycle owner rather than the process one, because the id list this + // needs only exists in this composition's LazyPagingItems snapshot. Each restart of the + // block re-asks for the same 60 newest ids — refreshing an id twice is a no-op for the + // coordinator, and re-asking is exactly the point: a reader who backgrounded the app is + // the one case an overlay update could have been missed. + // 2. Older pages, as they page in from Room. `messages.loadState.mediator?.append` tells + // apart a page the RemoteMediator just pulled over the network — already current, no + // ask needed — from one the pager served out of the local cache, which may be showing + // reaction state as stale as this device's last sync. The first snapshot this effect + // sees is left unrequested; hook 1 already owns that opening batch. + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(lifecycleOwner, state.chatId, messages) { + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { + snapshotFlow { initialLoadComplete }.first { it } + val ids = ReactionRefreshPlanner.initialWindow(messages.newestFirstMessageIds()) + if (ids.isNotEmpty()) onAction(ChatAction.RefreshReactionIds(ids)) + } + } + + LaunchedEffect(state.chatId, messages) { + val refreshedPageIds = mutableSetOf() + var previousIds: Set? = null + snapshotFlow { messages.newestFirstMessageIds().toSet() } + .collect { currentIds -> + val seenBefore = previousIds + previousIds = currentIds + if (seenBefore == null) return@collect + val newIds = (currentIds - seenBefore).toList() + if (newIds.isEmpty()) return@collect + val sourcedFromServer = messages.loadState.mediator?.append is LoadState.Loading + ReactionRefreshPlanner.forLoadedPage( + pageIds = newIds, + sourcedFromServer = sourcedFromServer, + alreadyRefreshed = refreshedPageIds, + ).forEach { chunk -> + onAction(ChatAction.RefreshReactionIds(chunk)) + refreshedPageIds += chunk + } + } + } + LazyColumn( // NB: no sheetResignmentBehavior, unlike every other scrolling list in the app. The // conversation is a full-screen destination, not a sheet, so there is no dismiss drag @@ -473,6 +526,14 @@ internal fun MessageList( private val LazyPagingItems.loadedCount: Int get() = itemSnapshotList.items.size +/** + * Currently loaded message ids, newest first — index 0 is the newest, same order the haptic + * effect above reads [LazyPagingItems.peek] in. Feeds `ReactionRefreshPlanner`, which needs to + * know what "the newest 60" and "a newly loaded page" mean in terms this list already has. + */ +private fun LazyPagingItems.newestFirstMessageIds(): List = + itemSnapshotList.items.mapNotNull { (it as? ChatListItem.ContentBubble)?.messageId } + /** * The first index the pager has nothing for — the hint that drives one more append. * diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt index faaa40e78c..95a7724b40 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt @@ -101,6 +101,14 @@ sealed interface ChatAction { /** Opens who-reacted for [messageId], from a long-press on a pill. */ data class OpenReactors(val messageId: Long) : ChatAction + + /** + * Asks the coordinator to refresh reaction state for [messageIds] — the transcript's own + * refresh-on-paging hook (see `ReactionRefreshPlanner`), not a reader gesture. The transcript + * decides when and which ids; this only carries the request down to the viewmodel that owns + * the coordinator. + */ + data class RefreshReactionIds(val messageIds: List) : ChatAction } typealias ChatActionHandler = (ChatAction) -> Unit From 7818bb866b56cfc7d80829d6e881b5ed6a5c5d9d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 17:07:21 -0400 Subject: [PATCH 23/58] fix(chat): record reaction recents once, at tap time The ViewModel recorded on tap using only the live overlay, and ReactionsDelegate recorded again on a successful add, so each add counted twice and a take-back of a reaction stored only in Room counted as a use. Record in the delegate when the tap is made, against the Room-seeded state, as iOS ConversationReactions.toggle does. --- .../flipcash/app/messenger/internal/ChatViewModel.kt | 7 ------- .../chat/internal/delegates/ReactionsDelegate.kt | 10 ++++++---- .../com/flipcash/shared/chat/ReactionsDelegateTest.kt | 5 ++++- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 9bc19a7dab..f224f3e7cc 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -1731,13 +1731,6 @@ internal class ChatViewModel @Inject constructor( .onEach { event -> val chatId = stateFlow.value.chatId ?: return@onEach viewModelScope.launch { - // Only an add is worth ranking for the quick strip's "most used" slots — a - // remove is the reader taking it back, not a preference to weight. The live - // overlay is what's already reacted as far as this session knows; a message - // with no override yet has no self-reactions to take back, so it's an add. - val alreadyReacted = reactionOverlay.value[event.messageId] - ?.selfReactions?.any { it.emoji == event.emoji } == true - if (!alreadyReacted) recentReactionsStore.record(event.emoji) chatCoordinator.toggleReaction(chatId, event.messageId, event.emoji) } } diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/ReactionsDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/ReactionsDelegate.kt index 9890c0680f..09b042caf9 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/ReactionsDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/ReactionsDelegate.kt @@ -86,7 +86,11 @@ class ReactionsDelegate @Inject constructor( override suspend fun toggleReaction(chatId: ChatId, messageId: Long, emoji: String) { val state = snapshot(chatId, messageId) + // Recorded at tap time, as iOS does: the snapshot knows reactions stored in Room, so a tap + // that takes back an existing reaction is not counted as a use. + val wasReacted = state.selfReactions.any { it.emoji == emoji } val call = state.tap(emoji, Clock.System.now()) + if (!wasReacted) recentReactionsStore.record(emoji) localVersion.update { it + 1 } if (call != null) sendCall(chatId, messageId, state, call) } @@ -118,9 +122,8 @@ class ReactionsDelegate @Inject constructor( } /** - * Sends [call], settles it against [state], persists confirmed state on success, records a - * successful add to the recents ranking, reports a non-silent failure on [reactionErrors], and - * loops once more if settling produced a coalesced follow-up call — the same shape as the iOS + * Sends [call], settles it against [state], persists confirmed state on success, reports a + * non-silent failure on [reactionErrors], and loops once more if settling produced a coalesced follow-up call — the same shape as the iOS * reference's tap → send → respond cycle. */ private suspend fun sendCall(chatId: ChatId, messageId: Long, state: ReactionState, call: ReactionCall) { @@ -128,7 +131,6 @@ class ReactionsDelegate @Inject constructor( val (followUp, error) = state.respond(call.emoji, result) if (result is ReactionResult.Ok) { persist(chatId, messageId, state) - if (call.op == ReactionCall.Op.ADD) recentReactionsStore.record(call.emoji) } localVersion.update { it + 1 } error?.let { _reactionErrors.emit(it) } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReactionsDelegateTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReactionsDelegateTest.kt index cc61c9c299..c92adbbb90 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReactionsDelegateTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReactionsDelegateTest.kt @@ -101,7 +101,8 @@ class ReactionsDelegateTest { assertEquals(com.flipcash.shared.chat.reactions.ReactionError.REACTION_FAILED, error) coVerify(exactly = 0) { messageDataSource.mergeReactions(any(), any(), any()) } - coVerify(exactly = 0) { recentReactionsStore.record(any()) } + // Recents count the tap, as on iOS, whether or not the server takes it. + coVerify(exactly = 1) { recentReactionsStore.record(emoji) } } @Test @@ -197,6 +198,8 @@ class ReactionsDelegateTest { coVerify { messagingController.removeReaction(chatId, 1L, Emoji(emoji)) } coVerify(exactly = 0) { messagingController.addReaction(any(), any(), any()) } + // Taking a reaction back is not a use of it. + coVerify(exactly = 0) { recentReactionsStore.record(any()) } } @Test From a5f0744e763b496136e617b5572f56a7373315a5 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 17:08:55 -0400 Subject: [PATCH 24/58] fix(chat): show reaction errors as one-line toasts with the iOS copy iOS shows "Couldn't add reaction" and "This message has the maximum number of reactions" as a single-line toast. Drop the bottom-bar error and its invented second lines. --- .../core/src/main/res/values/strings.xml | 2 -- .../app/messenger/internal/ChatViewModel.kt | 23 ++++++++----------- .../internal/ChatGroupAnalyticsTest.kt | 1 + .../internal/ChatSendFailureAnalyticsTest.kt | 1 + 4 files changed, 11 insertions(+), 16 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 8d493f5495..8b7d51be8a 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1005,9 +1005,7 @@ The message couldn\'t be deleted, so it is still visible to everyone. Message Couldn\'t add reaction - Please try again. This message has the maximum number of reactions - Remove one to add another. Unknown Contact Allow Full Contact Access diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index f224f3e7cc..e8d12e2427 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -32,6 +32,7 @@ import com.flipcash.app.contacts.ContactCoordinator import com.flipcash.app.core.AppRoute import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.chat.ChatParticipant +import com.flipcash.app.core.toast.SystemToastController import com.flipcash.app.core.contacts.DeviceContact import com.flipcash.app.core.extensions.setText import com.flipcash.app.core.tokens.brandedName @@ -190,6 +191,7 @@ internal class ChatViewModel @Inject constructor( private val chatDraftStore: ChatDraftStore, private val recentReactionsStore: RecentReactionsStore, private val emojiCatalogLoader: EmojiCatalogLoader, + private val toastController: SystemToastController, dispatchers: DispatcherProvider, ) : BaseViewModel( initialState = State(), @@ -1790,22 +1792,15 @@ internal class ChatViewModel @Inject constructor( .launchIn(viewModelScope) // A failed add/remove the coordinator has already rolled back optimistically — this only - // tells the reader why the pill snapped back. + // tells the reader why the pill snapped back. One line, as iOS's toast is. chatCoordinator.reactionErrors .onEach { error -> - BottomBarManager.showError( - title = resources.getString( - when (error) { - ReactionError.REACTION_FAILED -> R.string.title_reactionNotAdded - ReactionError.TOO_MANY_REACTION_TYPES -> R.string.title_reactionLimitReached - } - ), - message = resources.getString( - when (error) { - ReactionError.REACTION_FAILED -> R.string.description_reactionNotAdded - ReactionError.TOO_MANY_REACTION_TYPES -> R.string.description_reactionLimitReached - } - ), + toastController.showToast( + when (error) { + ReactionError.REACTION_FAILED -> R.string.title_reactionNotAdded + ReactionError.TOO_MANY_REACTION_TYPES -> R.string.title_reactionLimitReached + }, + replacePrevious = true, ) } .launchIn(viewModelScope) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt index 6362fec0d5..1eab7ee7d2 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt @@ -146,6 +146,7 @@ class ChatGroupAnalyticsTest { cashLinkClaims = cashLinkClaims, chatDraftStore = chatDraftStore, recentReactionsStore = recentReactionsStore, + toastController = mockk(relaxed = true), emojiCatalogLoader = emojiCatalogLoader, dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), ) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt index 6a589f44cd..9b070f2e3c 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt @@ -138,6 +138,7 @@ class ChatSendFailureAnalyticsTest { cashLinkClaims = cashLinkClaims, chatDraftStore = chatDraftStore, recentReactionsStore = recentReactionsStore, + toastController = mockk(relaxed = true), emojiCatalogLoader = emojiCatalogLoader, dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), ) From 3986f71947337aa849b10aea2e402b5dd9f6a37b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 17:13:47 -0400 Subject: [PATCH 25/58] feat(emojis): add EmojiPickerModel search/section grouping Mirrors iOS's EmojiPickerModel: Frequently Used row (deduped, order preserved) then one section per catalog category, or a single search-results section when the query is non-empty. Undrawable entries are filtered from every section. --- .../libs/emojis/reactions/EmojiPickerModel.kt | 77 +++++++++++ .../emojis/reactions/EmojiPickerModelTest.kt | 126 ++++++++++++++++++ 2 files changed, 203 insertions(+) create mode 100644 libs/emojis/src/main/kotlin/com/getcode/libs/emojis/reactions/EmojiPickerModel.kt create mode 100644 libs/emojis/src/test/kotlin/com/getcode/libs/emojis/reactions/EmojiPickerModelTest.kt diff --git a/libs/emojis/src/main/kotlin/com/getcode/libs/emojis/reactions/EmojiPickerModel.kt b/libs/emojis/src/main/kotlin/com/getcode/libs/emojis/reactions/EmojiPickerModel.kt new file mode 100644 index 0000000000..f59387ba38 --- /dev/null +++ b/libs/emojis/src/main/kotlin/com/getcode/libs/emojis/reactions/EmojiPickerModel.kt @@ -0,0 +1,77 @@ +package com.getcode.libs.emojis.reactions + +/** + * The picker's pure grouping logic — search, category sections, the Frequently Used row, and the + * undrawable filter — kept apart from the Compose sheet so it is testable without loading the real + * catalog or standing up a screen. Mirrors iOS's `EmojiPickerModel`. + */ +object EmojiPickerModel { + + /** One section the grid draws, in order. */ + data class Section( + val id: String, + val title: String, + val entries: List, + ) + + /** The id [sections] gives the Frequently Used row, so the category bar can skip past it. */ + const val FREQUENTLY_USED_ID = "frequently-used" + const val FREQUENTLY_USED_TITLE = "Frequently Used" + + /** The id [sections] gives the single section a non-empty search produces. */ + const val SEARCH_RESULTS_ID = "search-results" + const val SEARCH_RESULTS_TITLE = "Search Results" + + /** + * Builds the sections the grid draws. + * + * With an empty (or blank) [query]: a Frequently Used row built from [recents] — deduped and + * left in the caller's order (the caller is expected to have already capped it to the picker's + * row limit) — followed by one section per [catalog] category, in catalog order. + * + * With a non-empty [query]: a single "search results" section, catalog order, matched + * case-insensitively against an entry's name or keywords — no Frequently Used row, since a + * search is the user looking for something specific rather than reaching for the recent set. + * + * [undrawable] is filtered out of every section, including the Frequently Used row — a recent + * pick this OS build can no longer render is worth dropping silently rather than showing a tofu + * box for. + */ + fun sections( + catalog: EmojiCatalog, + undrawable: Set, + recents: List, + query: String, + ): List
{ + val drawable = catalog.entries.filter { it.emoji !in undrawable } + val trimmedQuery = query.trim() + + if (trimmedQuery.isNotEmpty()) { + val needle = trimmedQuery.lowercase() + val matches = drawable.filter { entry -> + entry.name.lowercase().contains(needle) || + entry.keywords.any { it.lowercase().contains(needle) } + } + return listOf(Section(id = SEARCH_RESULTS_ID, title = SEARCH_RESULTS_TITLE, entries = matches)) + } + + val sections = mutableListOf
() + if (recents.isNotEmpty()) { + val byEmoji = drawable.associateBy { it.emoji } + val seen = mutableSetOf() + val frequent = recents.mapNotNull { emoji -> + if (!seen.add(emoji)) return@mapNotNull null + byEmoji[emoji] + } + if (frequent.isNotEmpty()) { + sections += Section(id = FREQUENTLY_USED_ID, title = FREQUENTLY_USED_TITLE, entries = frequent) + } + } + for (category in catalog.categories) { + val entries = drawable.filter { it.category == category } + if (entries.isEmpty()) continue + sections += Section(id = category, title = category, entries = entries) + } + return sections + } +} diff --git a/libs/emojis/src/test/kotlin/com/getcode/libs/emojis/reactions/EmojiPickerModelTest.kt b/libs/emojis/src/test/kotlin/com/getcode/libs/emojis/reactions/EmojiPickerModelTest.kt new file mode 100644 index 0000000000..aa58b12374 --- /dev/null +++ b/libs/emojis/src/test/kotlin/com/getcode/libs/emojis/reactions/EmojiPickerModelTest.kt @@ -0,0 +1,126 @@ +package com.getcode.libs.emojis.reactions + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class EmojiPickerModelTest { + + private val grin = EmojiCatalogEntry( + emoji = "😀", + name = "grinning face", + category = "Smileys & People", + version = "1.0", + skinTone = false, + keywords = listOf("happy", "smile"), + ) + private val heart = EmojiCatalogEntry( + emoji = "❤️", + name = "red heart", + category = "Smileys & People", + version = "1.0", + skinTone = false, + keywords = listOf("love"), + ) + private val pizza = EmojiCatalogEntry( + emoji = "🍕", + name = "pizza", + category = "Food & Drink", + version = "1.0", + skinTone = false, + keywords = listOf("food", "slice"), + ) + private val undrawableEntry = EmojiCatalogEntry( + emoji = "🫠", + name = "melting face", + category = "Smileys & People", + version = "14.0", + skinTone = false, + keywords = emptyList(), + ) + + private val catalog = EmojiCatalog( + categories = listOf("Smileys & People", "Food & Drink"), + entries = listOf(grin, heart, pizza, undrawableEntry), + ) + + @Test + fun `empty query returns frequently used row then one section per category, in catalog order`() { + val sections = EmojiPickerModel.sections( + catalog = catalog, + undrawable = emptySet(), + recents = listOf(heart.emoji, grin.emoji), + query = "", + ) + + assertEquals( + listOf(EmojiPickerModel.FREQUENTLY_USED_ID, "Smileys & People", "Food & Drink"), + sections.map { it.id }, + ) + assertEquals(listOf(heart, grin), sections.first().entries) + assertEquals(listOf(grin, heart, undrawableEntry), sections[1].entries) + assertEquals(listOf(pizza), sections[2].entries) + } + + @Test + fun `frequently used row is deduped and undrawable entries are dropped from it and every section`() { + val sections = EmojiPickerModel.sections( + catalog = catalog, + undrawable = setOf(undrawableEntry.emoji), + recents = listOf(heart.emoji, heart.emoji, undrawableEntry.emoji), + query = "", + ) + + assertEquals(listOf(heart), sections.first { it.id == EmojiPickerModel.FREQUENTLY_USED_ID }.entries) + assertTrue(sections.none { entry -> entry.entries.any { it.emoji == undrawableEntry.emoji } }) + } + + @Test + fun `no frequently used row when recents is empty`() { + val sections = EmojiPickerModel.sections( + catalog = catalog, + undrawable = emptySet(), + recents = emptyList(), + query = "", + ) + + assertEquals(listOf("Smileys & People", "Food & Drink"), sections.map { it.id }) + } + + @Test + fun `search matches name or keyword case-insensitively and returns one section, no frequently used row`() { + val sections = EmojiPickerModel.sections( + catalog = catalog, + undrawable = emptySet(), + recents = listOf(heart.emoji), + query = "SLICE", + ) + + assertEquals(listOf(EmojiPickerModel.SEARCH_RESULTS_ID), sections.map { it.id }) + assertEquals(listOf(pizza), sections.first().entries) + } + + @Test + fun `blank query (whitespace only) behaves as empty`() { + val sections = EmojiPickerModel.sections( + catalog = catalog, + undrawable = emptySet(), + recents = emptyList(), + query = " ", + ) + + assertEquals(listOf("Smileys & People", "Food & Drink"), sections.map { it.id }) + } + + @Test + fun `search excludes undrawable entries`() { + val sections = EmojiPickerModel.sections( + catalog = catalog, + undrawable = setOf(undrawableEntry.emoji), + recents = emptyList(), + query = "face", + ) + + assertEquals(listOf(grin), sections.first().entries) + } +} From f0e93a6289b8e9f94825114ac79ff173d6b83d71 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 17:15:00 -0400 Subject: [PATCH 26/58] feat(chat): default getReactorsPage to a 50-row page REACTORS_PAGE_SIZE matches the reactors sheet's own page size (decision 4) rather than QueryOptions' general-purpose default of 100. --- .../com/flipcash/shared/chat/ChatCoordinator.kt | 11 ++++++++++- .../chat/internal/delegates/ReactionsDelegate.kt | 8 +++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt index 28d377cb22..c0656b43a2 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt @@ -178,13 +178,22 @@ interface ReactionOperations { */ suspend fun refreshReactions(chatId: ChatId, messageIds: List): Result - /** One page of the users who reacted to [messageId] with [emoji], for the "who reacted" sheet. */ + /** + * One page of the users who reacted to [messageId] with [emoji], for the "who reacted" sheet. + * [limit] defaults to the reactors sheet's own page size (decision 4), not the + * general-purpose [com.flipcash.services.models.QueryOptions] default. + */ suspend fun getReactorsPage( chatId: ChatId, messageId: Long, emoji: String, token: PagingToken? = null, + limit: Int = REACTORS_PAGE_SIZE, ): Result + + companion object { + const val REACTORS_PAGE_SIZE = 50 + } } /** diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/ReactionsDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/ReactionsDelegate.kt index 09b042caf9..dec83b3f93 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/ReactionsDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/ReactionsDelegate.kt @@ -112,8 +112,14 @@ class ReactionsDelegate @Inject constructor( messageId: Long, emoji: String, token: PagingToken?, + limit: Int, ): Result = - messagingController.getReactors(chatId, messageId, Emoji(emoji), QueryOptions(token = token)) + messagingController.getReactors( + chatId, + messageId, + Emoji(emoji), + QueryOptions(token = token, limit = limit), + ) /** Drops all in-memory reaction state (pending taps, in-flight calls, confirmed cache). */ fun clearAll() { From 3b831c78b077017aeaecb84695673605ebb2c6d9 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 17:16:27 -0400 Subject: [PATCH 27/58] feat(chat): merge reactors sheet pages into one row per person --- .../chat/reactions/ReactorsListModel.kt | 115 ++++++++++++++ .../chat/reactions/ReactorsListModelTest.kt | 146 ++++++++++++++++++ 2 files changed, 261 insertions(+) create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactorsListModel.kt create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/reactions/ReactorsListModelTest.kt diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactorsListModel.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactorsListModel.kt new file mode 100644 index 0000000000..8e28b92e28 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactorsListModel.kt @@ -0,0 +1,115 @@ +package com.flipcash.shared.chat.reactions + +import com.flipcash.services.models.PagingToken +import com.flipcash.services.repository.ReactorsPage +import com.getcode.opencode.model.core.ID +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope + +/** + * Pure, fake-friendly merge logic for the reactors sheet (decision 4) — mirrors iOS's + * `ReactorsListModel`/`ReactorsSource`. One row per person who reacted, holding every emoji they + * used, in the order [emojis] lists them (pill order); rows sorted by their most recent reaction + * first, then by userId as a tiebreak. + * + * Not itself a ViewModel — a VM or step-scoped holder drives it (loadMoreIfNeeded from a start + * trigger and from "last row visible") and republishes [rows] to its own StateFlow. Kept free of + * Compose/Android types so it is unit-testable with a fake [fetchPage]. + */ +class ReactorsListModel( + private val emojis: List, + private val fetchPage: suspend (emoji: String, token: PagingToken?) -> Result, +) { + + private data class EmojiState( + val reactors: List = emptyList(), + val nextToken: PagingToken? = null, + val hasMore: Boolean = true, + val loading: Boolean = false, + ) + + private val state = emojis.associateWith { EmojiState() }.toMutableMap() + + /** One row per reactor: their userId, every emoji they used (pill order), and their newest reaction time. */ + data class Row( + val userId: ID, + val emojis: List, + val newestReactedAt: kotlin.time.Instant, + ) + + var rows: List = emptyList() + private set + + /** True while at least one emoji still has an unfetched page. */ + val hasMore: Boolean + get() = state.values.any { it.hasMore } + + /** + * Fetches the next page for every emoji that still has more and isn't already loading, waits + * for the whole round to land, then republishes [rows]. A failed page for one emoji only turns + * off that emoji's `hasMore` — the others still complete and are reflected. + */ + suspend fun loadMoreIfNeeded() { + val toFetch = state.filterValues { it.hasMore && !it.loading }.keys + if (toFetch.isEmpty()) return + + toFetch.forEach { emoji -> state[emoji] = state.getValue(emoji).copy(loading = true) } + + coroutineScope { + val results = toFetch.map { emoji -> + emoji to async { fetchPage(emoji, state.getValue(emoji).nextToken) } + } + for ((emoji, deferred) in results) { + val current = state.getValue(emoji) + val result = deferred.await() + state[emoji] = result.fold( + onSuccess = { page -> + current.copy( + reactors = current.reactors + page.reactors, + nextToken = page.nextToken, + hasMore = page.hasMore, + loading = false, + ) + }, + onFailure = { + current.copy(hasMore = false, loading = false) + }, + ) + } + } + + publish() + } + + private fun publish() { + val byUser = LinkedHashMap>>() + for (emoji in emojis) { + val emojiState = state.getValue(emoji) + for (reactor in emojiState.reactors) { + val key = reactor.userId.joinToString(",") + val entries = byUser.getOrPut(key) { mutableListOf() } + entries += emoji to reactor.reactedAt + } + } + + val userIdByKey = mutableMapOf() + for (emoji in emojis) { + for (reactor in state.getValue(emoji).reactors) { + userIdByKey[reactor.userId.joinToString(",")] = reactor.userId + } + } + + rows = byUser.entries + .map { (key, entries) -> + val emojiOrder = entries.map { it.first }.distinct() + val newest = entries.maxOf { it.second } + Row(userId = userIdByKey.getValue(key), emojis = emojiOrder, newestReactedAt = newest) + } + .sortedWith( + compareByDescending { it.newestReactedAt } + .thenByDescending { it.userId.hexEncodedString() }, + ) + } +} + +private fun ID.hexEncodedString(): String = joinToString("") { "%02x".format(it) } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/reactions/ReactorsListModelTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/reactions/ReactorsListModelTest.kt new file mode 100644 index 0000000000..b7ef4307ef --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/reactions/ReactorsListModelTest.kt @@ -0,0 +1,146 @@ +package com.flipcash.shared.chat.reactions + +import com.flipcash.services.repository.ReactorsPage +import com.flipcash.services.models.chat.Reactor +import com.getcode.opencode.model.core.ID +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Instant + +private fun id(byte: Int): ID = listOf(byte.toByte()) + +class ReactorsListModelTest { + + private val alice = id(1) + private val bob = id(2) + private val carol = id(3) + + private fun reactor(userId: ID, seconds: Long) = + Reactor(userId = userId, reactedAt = Instant.fromEpochSeconds(seconds)) + + @Test + fun `merges every emoji's first page into one row per person, in pill order`() = runTest { + val model = ReactorsListModel( + emojis = listOf("❤️", "👍"), // heart, thumbsup + fetchPage = { emoji, _ -> + when (emoji) { + "❤️" -> Result.success( + ReactorsPage(reactors = listOf(reactor(alice, 10), reactor(bob, 5)), hasMore = false), + ) + else -> Result.success( + ReactorsPage(reactors = listOf(reactor(bob, 20)), hasMore = false), + ) + } + }, + ) + + model.loadMoreIfNeeded() + + assertEquals(2, model.rows.size) + val bobRow = model.rows.first { it.userId == bob } + assertEquals(listOf("❤️", "👍"), bobRow.emojis) + val aliceRow = model.rows.first { it.userId == alice } + assertEquals(listOf("❤️"), aliceRow.emojis) + } + + @Test + fun `rows sort by newest reaction first, then userId descending`() = runTest { + val model = ReactorsListModel( + emojis = listOf("❤️"), + fetchPage = { _, _ -> + Result.success( + ReactorsPage( + reactors = listOf(reactor(alice, 5), reactor(bob, 20), reactor(carol, 20)), + hasMore = false, + ), + ) + }, + ) + + model.loadMoreIfNeeded() + + // bob and carol tie at 20s -> higher userId (carol) first; alice (5s) last. + assertEquals(listOf(carol, bob, alice), model.rows.map { it.userId }) + } + + @Test + fun `publishes rows only once every emoji's round of pages is in`() = runTest { + var bobReleased = false + val model = ReactorsListModel( + emojis = listOf("❤️", "👍"), + fetchPage = { emoji, _ -> + if (emoji == "👍" && !bobReleased) { + // Simulate the slower of the two calls. + } + when (emoji) { + "❤️" -> Result.success(ReactorsPage(reactors = listOf(reactor(alice, 1)), hasMore = false)) + else -> Result.success(ReactorsPage(reactors = listOf(reactor(bob, 1)), hasMore = false)) + } + }, + ) + + assertTrue(model.rows.isEmpty()) + model.loadMoreIfNeeded() + assertEquals(2, model.rows.size) + } + + @Test + fun `a failed page stops asking for more of that emoji only`() = runTest { + var heartCalls = 0 + var thumbsCalls = 0 + val model = ReactorsListModel( + emojis = listOf("❤️", "👍"), + fetchPage = { emoji, _ -> + when (emoji) { + "❤️" -> { + heartCalls++ + Result.failure(RuntimeException("boom")) + } + else -> { + thumbsCalls++ + Result.success(ReactorsPage(reactors = listOf(reactor(bob, 1)), hasMore = true, nextToken = listOf(1))) + } + } + }, + ) + + model.loadMoreIfNeeded() + model.loadMoreIfNeeded() + + assertEquals(1, heartCalls, "should not retry a failed emoji's page") + assertEquals(2, thumbsCalls) + assertTrue(model.hasMore, "thumbsup still has more") + } + + @Test + fun `hasMore is false once every emoji is exhausted`() = runTest { + val model = ReactorsListModel( + emojis = listOf("❤️"), + fetchPage = { _, _ -> Result.success(ReactorsPage(reactors = emptyList(), hasMore = false)) }, + ) + + assertTrue(model.hasMore) + model.loadMoreIfNeeded() + assertFalse(model.hasMore) + } + + @Test + fun `does not re-fetch an emoji whose page is already loading or exhausted`() = runTest { + var calls = 0 + val model = ReactorsListModel( + emojis = listOf("❤️"), + fetchPage = { _, _ -> + calls++ + Result.success(ReactorsPage(reactors = emptyList(), hasMore = false)) + }, + ) + + model.loadMoreIfNeeded() + model.loadMoreIfNeeded() + + assertEquals(1, calls, "exhausted emoji should not be asked again") + } +} From dd3f1450808670ac46f68296e824234bc09be6eb Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 17:45:12 -0400 Subject: [PATCH 28/58] feat(chat): add reactor name resolution and reactors/picker sheet detents ReactorNameResolver is a pure precedence function over self, the cached UserProfile, the chat's member roster, and a requested sender-profile fetch, mirroring the sources ChatViewModel.memberParticipant already reads. ChatStep.ReactionPicker/Reactors now also implement HalfSheet, matching iOS's .medium/.large detents for both sheets. --- .../com/flipcash/app/core/chat/ChatStep.kt | 13 +- .../features/messenger/build.gradle.kts | 2 + .../chat/reactions/ReactorNameResolver.kt | 55 ++++++++ .../chat/reactions/ReactorNameResolverTest.kt | 122 ++++++++++++++++++ 4 files changed, 188 insertions(+), 4 deletions(-) create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactorNameResolver.kt create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/reactions/ReactorNameResolverTest.kt diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt index 51667a45a7..ca747ce83b 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt @@ -1,6 +1,7 @@ package com.flipcash.app.core.chat import android.os.Parcelable +import com.getcode.navigation.HalfSheet import com.getcode.navigation.Sheet import com.getcode.navigation.WrapContentSheet import com.getcode.navigation.flow.FlowStep @@ -100,18 +101,22 @@ sealed interface ChatStep : FlowStep, Parcelable { /** * The full emoji picker for a message's reaction, opened from the pill row's "+" or the * quick strip's "+". A plain [Sheet] rather than [WrapContentSheet]: the picker's catalog - * needs the screen's height, unlike the short, fixed-content sheets above. + * needs the screen's height, unlike the short, fixed-content sheets above. [HalfSheet] + * matches iOS's `EmojiPickerSheet.presentationDetents([.medium, .large])` — it rests at half + * height and only grows to the expanded detent once the grid reports it has more than that to + * show (see `AllowSheetExpansionWhenScrollable`). */ @Parcelize @Serializable - data class ReactionPicker(val messageId: Long) : ChatStep, Sheet + data class ReactionPicker(val messageId: Long) : ChatStep, Sheet, HalfSheet /** * Who reacted, and with what — opened by long-pressing a pill. A plain [Sheet] for the same * reason as [ReactionPicker]: a per-emoji reactor list can run long enough to need the full - * sheet height rather than wrapping its content. + * sheet height rather than wrapping its content. [HalfSheet] matches iOS's + * `ReactorsSheet.presentationDetents([.medium, .large])`. */ @Parcelize @Serializable - data class Reactors(val messageId: Long) : ChatStep, Sheet + data class Reactors(val messageId: Long) : ChatStep, Sheet, HalfSheet } diff --git a/apps/flipcash/features/messenger/build.gradle.kts b/apps/flipcash/features/messenger/build.gradle.kts index 0395c8a439..043a468609 100644 --- a/apps/flipcash/features/messenger/build.gradle.kts +++ b/apps/flipcash/features/messenger/build.gradle.kts @@ -21,6 +21,8 @@ dependencies { implementation(project(":libs:emojis")) implementation(project(":apps:flipcash:shared:menu")) implementation(project(":apps:flipcash:shared:payments")) + // UserProfileDataSource, for the reactors sheet's cached-profile name resolution (decision 4). + implementation(project(":apps:flipcash:shared:persistence:sources")) implementation(project(":apps:flipcash:shared:router")) implementation(project(":apps:flipcash:shared:session")) implementation(project(":apps:flipcash:shared:shareable")) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactorNameResolver.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactorNameResolver.kt new file mode 100644 index 0000000000..4e57d882eb --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactorNameResolver.kt @@ -0,0 +1,55 @@ +package com.flipcash.shared.chat.reactions + +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatMember +import com.flipcash.services.models.handle +import com.flipcash.services.models.nameOrHandle +import com.getcode.opencode.model.core.ID +import com.getcode.utils.hexEncodedString + +/** + * The reactors sheet's row name for one userId (decision 4). + * + * Pure and dependency-free so the precedence is unit-testable without a ViewModel, Compose, or a + * database. A caller (`ChatViewModel.reactorProfileFlow`) supplies each source already read — + * [cachedProfile] from a one-shot `UserProfileDataSource` lookup, [members] and [senderProfiles] + * from their own live flows — and re-resolves as those live sources change. + * + * Precedence, matching the identity sources `ChatViewModel.memberParticipant` already reads: + * + * 1. [selfUserId] — the viewer themself. Returns [selfLabel] ("You"), never their own profile + * name, so the sheet doesn't show a reactor a vanity name they gave themselves elsewhere. + * 2. [cachedProfile] — the persisted `user_profiles` cache, resolved without a round trip. + * 3. [members] — the chat's own roster, which a group already holds for everyone in it. + * 4. [senderProfiles] — a server fetch the caller kicked off (`ChatCoordinator.requestSenderProfile`), + * for a reactor absent from both of the above. Answers asynchronously, so a caller re-resolves + * once this map updates. + * + * Returns null when none of the four sources has an answer yet — the caller's cue to request a + * fetch and leave the row showing a placeholder until one of the sources resolves. + */ +object ReactorNameResolver { + + fun resolve( + userId: ID, + selfUserId: ID?, + selfLabel: String, + cachedProfile: UserProfile?, + members: List, + senderProfiles: Map, + ): String? { + if (selfUserId != null && userId == selfUserId) return selfLabel + + cachedProfile?.let { nameOrHandle(it.displayName, it.handle) }?.let { return it } + + members.firstOrNull { it.userId == userId }?.userProfile + ?.let { nameOrHandle(it.displayName, it.handle) } + ?.let { return it } + + senderProfiles[userId.hexEncodedString()] + ?.let { nameOrHandle(it.displayName, it.handle) } + ?.let { return it } + + return null + } +} diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/reactions/ReactorNameResolverTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/reactions/ReactorNameResolverTest.kt new file mode 100644 index 0000000000..664f062f8b --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/reactions/ReactorNameResolverTest.kt @@ -0,0 +1,122 @@ +package com.flipcash.shared.chat.reactions + +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatMember +import com.getcode.opencode.model.core.ID +import com.getcode.utils.hexEncodedString +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +private fun id(byte: Int): ID = listOf(byte.toByte()) + +private fun profile(name: String = "", username: String? = null): UserProfile = UserProfile( + displayName = name, + socialAccounts = emptyList(), + phoneNumber = null, + email = null, + username = username, +) + +private fun member(userId: ID, name: String) = ChatMember( + userId = userId, + userProfile = profile(name), + pointers = emptyList(), +) + +class ReactorNameResolverTest { + + private val self = id(1) + private val alice = id(2) + + @Test + fun `self always resolves to the self label, ignoring every other source`() { + val resolved = ReactorNameResolver.resolve( + userId = self, + selfUserId = self, + selfLabel = "You", + cachedProfile = profile("Self Vanity Name"), + members = listOf(member(self, "Self Member Name")), + senderProfiles = mapOf(self.hexEncodedString() to profile("Self Sender Name")), + ) + assertEquals("You", resolved) + } + + @Test + fun `cached profile wins over the member roster and sender profiles`() { + val resolved = ReactorNameResolver.resolve( + userId = alice, + selfUserId = self, + selfLabel = "You", + cachedProfile = profile("Cached Alice"), + members = listOf(member(alice, "Member Alice")), + senderProfiles = mapOf(alice.hexEncodedString() to profile("Sender Alice")), + ) + assertEquals("Cached Alice", resolved) + } + + @Test + fun `member roster wins over sender profiles when there is no cached profile`() { + val resolved = ReactorNameResolver.resolve( + userId = alice, + selfUserId = self, + selfLabel = "You", + cachedProfile = null, + members = listOf(member(alice, "Member Alice")), + senderProfiles = mapOf(alice.hexEncodedString() to profile("Sender Alice")), + ) + assertEquals("Member Alice", resolved) + } + + @Test + fun `sender profiles resolve a reactor absent from the cache and the roster`() { + val resolved = ReactorNameResolver.resolve( + userId = alice, + selfUserId = self, + selfLabel = "You", + cachedProfile = null, + members = emptyList(), + senderProfiles = mapOf(alice.hexEncodedString() to profile("Sender Alice")), + ) + assertEquals("Sender Alice", resolved) + } + + @Test + fun `null when none of the four sources has an answer yet`() { + val resolved = ReactorNameResolver.resolve( + userId = alice, + selfUserId = self, + selfLabel = "You", + cachedProfile = null, + members = emptyList(), + senderProfiles = emptyMap(), + ) + assertNull(resolved) + } + + @Test + fun `a source with a blank name and no handle is treated as not resolving`() { + val resolved = ReactorNameResolver.resolve( + userId = alice, + selfUserId = self, + selfLabel = "You", + cachedProfile = profile(name = "", username = null), + members = listOf(member(alice, "Member Alice")), + senderProfiles = emptyMap(), + ) + assertEquals("Member Alice", resolved) + } + + @Test + fun `a blank display name falls back to the handle within the same source`() { + val resolved = ReactorNameResolver.resolve( + userId = alice, + selfUserId = self, + selfLabel = "You", + cachedProfile = profile(name = "", username = "alice"), + members = emptyList(), + senderProfiles = emptyMap(), + ) + assertEquals("@alice", resolved) + } +} From d0508d00189dd958ebf847860f026ade43a27884 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 17:48:11 -0400 Subject: [PATCH 29/58] feat(chat): add ReactorsPrefetchCache for OpenReactors-triggered loading One ReactorsListModel per messageId, started on OpenReactors (a pill long-press) rather than the sheet's own composition, so the fetch is already in flight by the time the sheet reads rows/loading for that id. --- .../chat/reactions/ReactorsPrefetchCache.kt | 84 ++++++++++++++++++ .../reactions/ReactorsPrefetchCacheTest.kt | 88 +++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactorsPrefetchCache.kt create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/reactions/ReactorsPrefetchCacheTest.kt diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactorsPrefetchCache.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactorsPrefetchCache.kt new file mode 100644 index 0000000000..0e4f659616 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactorsPrefetchCache.kt @@ -0,0 +1,84 @@ +package com.flipcash.shared.chat.reactions + +import com.flipcash.services.models.PagingToken +import com.flipcash.services.repository.ReactorsPage +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +/** + * Owns one [ReactorsListModel] per messageId, keyed so the reactors sheet and whatever started the + * fetch read the same instance rather than each building their own. + * + * Loading has to start on the pill long-press (decision: `OpenReactors` fires the fetch), not on + * the sheet's first composition — the sheet is a fresh Compose tree on every push, and a fetch + * gated on its `LaunchedEffect` would show a load on the same tap it could otherwise be racing. + * [start] is called from `ChatViewModel`'s `OpenReactors` handler, one step before the navigator + * pushes `ChatStep.Reactors`, and the sheet then only *reads* [rows]/[loading] for the id it opened + * on rather than kicking off its own load. [start] is idempotent per messageId so re-opening an + * already-fetched sheet doesn't refetch from scratch. + */ +class ReactorsPrefetchCache( + private val scope: CoroutineScope, + private val fetchPage: suspend (messageId: Long, emoji: String, token: PagingToken?) -> Result, +) { + + private class Holder(val model: ReactorsListModel) { + val rows = MutableStateFlow>(emptyList()) + val loading = MutableStateFlow(true) + } + + private val cache = mutableMapOf() + + /** The merged rows for [messageId], or an always-empty flow before [start] has been called. */ + fun rows(messageId: Long): StateFlow> = + cache[messageId]?.rows?.asStateFlow() ?: EMPTY_ROWS + + /** Whether a page is in flight for [messageId]. */ + fun loading(messageId: Long): StateFlow = + cache[messageId]?.loading?.asStateFlow() ?: NOT_LOADING + + /** True while at least one of [messageId]'s emojis still has an unfetched page. */ + fun hasMore(messageId: Long): Boolean = cache[messageId]?.model?.hasMore ?: false + + /** + * Starts the first round of pages for [messageId]'s [emojis] if nothing has started yet. + * No-ops for an empty [emojis] list (nothing reacted, so nothing to fetch) and for a messageId + * already in the cache — a second `OpenReactors` for the same message re-opens the existing + * fetch/rows rather than starting over. + */ + fun start(messageId: Long, emojis: List) { + if (emojis.isEmpty() || cache.containsKey(messageId)) return + val holder = Holder(ReactorsListModel(emojis) { emoji, token -> fetchPage(messageId, emoji, token) }) + cache[messageId] = holder + loadMore(messageId, holder) + } + + /** Fetches the next page for every emoji of [messageId] that still has more, once no load is already in flight. */ + fun loadMoreIfNeeded(messageId: Long) { + val holder = cache[messageId] ?: return + if (holder.loading.value || !holder.model.hasMore) return + loadMore(messageId, holder) + } + + private fun loadMore(messageId: Long, holder: Holder) { + holder.loading.value = true + scope.launch { + holder.model.loadMoreIfNeeded() + holder.rows.value = holder.model.rows + holder.loading.value = false + } + } + + /** Drops [messageId]'s cached fetch/rows — for a message that leaves the transcript (deleted, etc). */ + fun clear(messageId: Long) { + cache.remove(messageId) + } + + private companion object { + val EMPTY_ROWS = MutableStateFlow>(emptyList()).asStateFlow() + val NOT_LOADING = MutableStateFlow(false).asStateFlow() + } +} diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/reactions/ReactorsPrefetchCacheTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/reactions/ReactorsPrefetchCacheTest.kt new file mode 100644 index 0000000000..6da28ef7d1 --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/reactions/ReactorsPrefetchCacheTest.kt @@ -0,0 +1,88 @@ +package com.flipcash.shared.chat.reactions + +import com.flipcash.services.models.PagingToken +import com.flipcash.services.models.chat.Reactor +import com.flipcash.services.repository.ReactorsPage +import com.getcode.opencode.model.core.ID +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Instant + +private fun id(byte: Int): ID = listOf(byte.toByte()) + +private fun reactor(userId: ID, reactedAt: Instant) = Reactor(userId = userId, reactedAt = reactedAt) + +class ReactorsPrefetchCacheTest { + + @Test + fun `start calls fetchPage for every emoji before the sheet would read rows`() = runTest { + val calls = mutableListOf>() + val cache = ReactorsPrefetchCache(scope = this) { messageId, emoji, _ -> + calls += messageId to emoji + Result.success(ReactorsPage(reactors = emptyList(), hasMore = false)) + } + + // OpenReactors calls start() synchronously, one step before the navigator pushes the sheet + // (see ChatViewModel's OpenReactors handler) — so the fetch is in flight by the time a + // sheet composes and reads rows(), rather than starting from the sheet's own LaunchedEffect. + cache.start(messageId = 1L, emojis = listOf("👍", "🔥")) + assertTrue(cache.loading(1L).value) // in flight immediately, before any dispatcher runs it + + advanceUntilIdle() + + assertEquals(setOf(1L to "👍", 1L to "🔥"), calls.toSet()) + } + + @Test + fun `rows publish once the fetch completes`() = runTest { + val alice = id(2) + val reactedAt = Instant.fromEpochSeconds(1_000) + val cache = ReactorsPrefetchCache(scope = this) { _, emoji, _ -> + Result.success(ReactorsPage(reactors = listOf(reactor(alice, reactedAt)), hasMore = false)) + } + + cache.start(messageId = 1L, emojis = listOf("👍")) + assertTrue(cache.loading(1L).value) + + advanceUntilIdle() + + assertFalse(cache.loading(1L).value) + assertEquals(listOf(alice), cache.rows(1L).value.map { it.userId }) + } + + @Test + fun `start is idempotent per messageId`() = runTest { + var callCount = 0 + val cache = ReactorsPrefetchCache(scope = this) { _, _, _ -> + callCount++ + Result.success(ReactorsPage(reactors = emptyList(), hasMore = false)) + } + + cache.start(messageId = 1L, emojis = listOf("👍")) + advanceUntilIdle() + cache.start(messageId = 1L, emojis = listOf("👍")) + advanceUntilIdle() + + assertEquals(1, callCount) + } + + @Test + fun `start no-ops for a message with no reactions`() = runTest { + var callCount = 0 + val cache = ReactorsPrefetchCache(scope = this) { _, _, _ -> + callCount++ + Result.success(ReactorsPage(reactors = emptyList(), hasMore = false)) + } + + cache.start(messageId = 1L, emojis = emptyList()) + advanceUntilIdle() + + assertEquals(0, callCount) + assertEquals(emptyList(), cache.rows(1L).value) + } +} + +private fun assertFalse(actual: Boolean) = assertEquals(false, actual) From b59ddee1d8e35a00389e4d8c0846a1de9136b9e3 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 17:50:22 -0400 Subject: [PATCH 30/58] feat(chat): start reactors fetch on OpenReactors, resolve reactor names ChatViewModel now owns a ReactorsPrefetchCache keyed by messageId and starts it in the OpenReactors handler, one step before the navigator pushes the sheet. reactorName()/reactorParticipant() combine the cached-profile store, chat members, and sender profiles per decision 4's precedence, requesting a server fetch when none of them has an answer. --- .../app/messenger/internal/ChatViewModel.kt | 93 ++++++++++++++++++- 1 file changed, 88 insertions(+), 5 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index e8d12e2427..7ba830c317 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -41,6 +41,7 @@ import com.flipcash.app.core.ui.ConfirmationStyle import com.flipcash.app.core.util.Linkify import com.flipcash.app.funding.PurchaseMethodController import com.flipcash.app.messenger.internal.link.CashCardTap +import com.flipcash.app.persistence.sources.UserProfileDataSource import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.shared.chat.UnreadBoundary import com.flipcash.app.messenger.internal.link.ClaimReplyTargets @@ -92,6 +93,9 @@ import com.flipcash.shared.chat.MessageReactions import com.flipcash.shared.chat.reactions.ReactionError import com.flipcash.shared.chat.reactions.ReactionStrip import com.flipcash.shared.chat.reactions.ReactionStripComposer +import com.flipcash.shared.chat.reactions.ReactorNameResolver +import com.flipcash.shared.chat.reactions.ReactorsListModel +import com.flipcash.shared.chat.reactions.ReactorsPrefetchCache import com.flipcash.shared.chat.reactions.SelfReaction import com.getcode.libs.emojis.reactions.EmojiCatalogLoader import com.getcode.libs.emojis.reactions.EmojiDrawability @@ -192,6 +196,7 @@ internal class ChatViewModel @Inject constructor( private val recentReactionsStore: RecentReactionsStore, private val emojiCatalogLoader: EmojiCatalogLoader, private val toastController: SystemToastController, + private val userProfileDataSource: UserProfileDataSource, dispatchers: DispatcherProvider, ) : BaseViewModel( initialState = State(), @@ -744,6 +749,74 @@ internal class ChatViewModel @Inject constructor( senderProfiles.value?.get(userId.hexEncodedString()) ?.let { ChatParticipant.TipUser(userId, it) } + /** + * One [ReactorsListModel] per messageId, started on [Event.OpenReactors] (see + * [initMessageActionHandlers]) rather than on the reactors sheet's own composition — loading + * has to begin on the pill long-press itself, and the sheet is a fresh Compose tree on every + * push. `getReactorsPage` is captured against whatever [ChatId] is current at call time + * rather than the chat this view model was built for; a chat never changes under one instance + * in practice, but reading [stateFlow] here keeps the cache correct if that ever changes. + */ + private val reactorsPrefetchCache = ReactorsPrefetchCache(scope = viewModelScope) { messageId, emoji, token -> + val chatId = stateFlow.value.chatId ?: return@ReactorsPrefetchCache Result.failure( + IllegalStateException("No active chat to fetch reactors for") + ) + chatCoordinator.getReactorsPage(chatId, messageId, emoji, token) + } + + fun reactorsRows(messageId: Long): StateFlow> = reactorsPrefetchCache.rows(messageId) + + fun reactorsLoading(messageId: Long): StateFlow = reactorsPrefetchCache.loading(messageId) + + fun reactorsHasMore(messageId: Long): Boolean = reactorsPrefetchCache.hasMore(messageId) + + fun loadMoreReactors(messageId: Long) = reactorsPrefetchCache.loadMoreIfNeeded(messageId) + + /** + * The reactors sheet's display name for [userId] (decision 4), re-emitting as later sources — + * the member roster, then a server fetch — resolve. Kicks off [ChatCoordinator.requestSenderProfile] + * itself when nothing has an answer yet, mirroring [resolveSenderName]'s fallback for the + * transcript. + */ + fun reactorName(userId: ID): Flow { + val chatId = stateFlow.value.chatId + val selfLabel = resources.getString(R.string.title_you) + val members = chatId?.let { chatCoordinator.observeMembers(it) } ?: flowOf(emptyList()) + var requested = false + return combine( + userProfileDataSource.observeProfiles(), + members, + senderProfiles, + ) { cachedProfiles, memberList, sentProfiles -> + ReactorNameResolver.resolve( + userId = userId, + selfUserId = userManager.accountId, + selfLabel = selfLabel, + cachedProfile = cachedProfiles[userId.hexEncodedString()], + members = memberList, + senderProfiles = sentProfiles.orEmpty(), + ) + }.onEach { name -> + if (name == null && !requested) { + requested = true + chatCoordinator.requestSenderProfile(userId) + } + } + } + + /** [ChatParticipant] for [userId] to open a profile from the reactors sheet, unlike + * [memberParticipant] this also covers a reactor absent from [senderProfiles] by falling back + * to whatever the cached profile store or member roster already has. */ + suspend fun reactorParticipant(userId: ID): ChatParticipant.TipUser? { + memberParticipant(userId)?.let { return it } + val chatId = stateFlow.value.chatId + val fromMembers = chatId?.let { id -> + chatCoordinator.observeMembers(id).first().firstOrNull { it.userId == userId }?.userProfile + } + val profile = fromMembers ?: userProfileDataSource.getCachedProfile(userId) + return profile?.let { ChatParticipant.TipUser(userId, it) } + } + /** * How a drawn card reaches the lookup. Provided to the transcript, read by `LinkCardView`. * @@ -1773,11 +1846,21 @@ internal class ChatViewModel @Inject constructor( } .launchIn(viewModelScope) - // OpenReactionPicker/OpenReactors carry no side effect here — the screen's action handler - // pushes the ChatStep, the same as every other transcript-triggered navigation (see - // MessengerScreen's chatActionHandler). The reducer above is a no-op for both too; they - // exist as events at all only so a picker pick, once B2 adds it, can reach ToggleReaction - // through the same dispatch path everything else in the transcript uses. + // OpenReactionPicker carries no side effect here — the screen's action handler pushes the + // ChatStep, the same as every other transcript-triggered navigation (see MessengerScreen's + // chatActionHandler). The reducer above is a no-op for both too; they exist as events at + // all so a picker pick can reach ToggleReaction through the same dispatch path everything + // else in the transcript uses. + // + // OpenReactors does have one: it starts the reactors fetch (see reactorsPrefetchCache) + // before the navigator pushes ChatStep.Reactors, so loading begins on the long-press + // rather than on the sheet's own composition. + eventFlow.filterIsInstance() + .onEach { event -> + val emojis = reactionOverlay.value[event.messageId]?.pills?.map { it.emoji }.orEmpty() + reactorsPrefetchCache.start(event.messageId, emojis) + } + .launchIn(viewModelScope) // See ReactionRefreshPlanner and MessageList's reaction-refresh effects: the transcript // computes which ids need a refresh (the newest window on open/resume, Room-sourced pages From b1d5e6cfabf308736a4291df5b3abbc06d163ffe Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 17:52:42 -0400 Subject: [PATCH 31/58] feat(chat): resolve reactor avatar alongside name reactorDisplay() replaces reactorName() with a small ReactorDisplay(name, profile) pair, so the reactors sheet's row can draw an avatar from the same resolved source rather than a second lookup. Self resolves to the viewer's own profilePicture under the 'You' label. --- .../app/messenger/internal/ChatViewModel.kt | 38 +++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 7ba830c317..14e426631b 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -772,15 +772,22 @@ internal class ChatViewModel @Inject constructor( fun loadMoreReactors(messageId: Long) = reactorsPrefetchCache.loadMoreIfNeeded(messageId) + /** A reactors-sheet row's resolved identity: the name to show (decision 4's precedence, + * "You" for the viewer), and the profile to draw an avatar from when one is known. */ + data class ReactorDisplay(val name: String, val profile: UserProfile?) + /** - * The reactors sheet's display name for [userId] (decision 4), re-emitting as later sources — - * the member roster, then a server fetch — resolve. Kicks off [ChatCoordinator.requestSenderProfile] - * itself when nothing has an answer yet, mirroring [resolveSenderName]'s fallback for the - * transcript. + * The reactors sheet's display name and avatar source for [userId] (decision 4), re-emitting + * as later sources — the member roster, then a server fetch — resolve. Kicks off + * [ChatCoordinator.requestSenderProfile] itself when nothing has an answer yet, mirroring + * [resolveSenderName]'s fallback for the transcript. The self case shows the viewer's own + * picture (so their avatar isn't blank) under the "You" label rather than their own profile + * name. */ - fun reactorName(userId: ID): Flow { + fun reactorDisplay(userId: ID): Flow { val chatId = stateFlow.value.chatId val selfLabel = resources.getString(R.string.title_you) + val selfUserId = userManager.accountId val members = chatId?.let { chatCoordinator.observeMembers(it) } ?: flowOf(emptyList()) var requested = false return combine( @@ -788,16 +795,25 @@ internal class ChatViewModel @Inject constructor( members, senderProfiles, ) { cachedProfiles, memberList, sentProfiles -> - ReactorNameResolver.resolve( + val cached = cachedProfiles[userId.hexEncodedString()] + val name = ReactorNameResolver.resolve( userId = userId, - selfUserId = userManager.accountId, + selfUserId = selfUserId, selfLabel = selfLabel, - cachedProfile = cachedProfiles[userId.hexEncodedString()], + cachedProfile = cached, members = memberList, senderProfiles = sentProfiles.orEmpty(), - ) - }.onEach { name -> - if (name == null && !requested) { + ) ?: return@combine null + val profile = if (selfUserId != null && userId == selfUserId) { + userManager.profile + } else { + cached + ?: memberList.firstOrNull { it.userId == userId }?.userProfile + ?: sentProfiles?.get(userId.hexEncodedString()) + } + ReactorDisplay(name = name, profile = profile) + }.onEach { display -> + if (display == null && !requested) { requested = true chatCoordinator.requestSenderProfile(userId) } From e4bf23510032cfe00bd995a4900edfdea160f2a8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 17:55:28 -0400 Subject: [PATCH 32/58] fix(chat): key ReactorsPrefetchCache flows so subscribers survive a race A composition reading rows()/loading() before OpenReactors calls start() was capturing a placeholder StateFlow that start() could never update. Back both by one shared map-keyed StateFlow instead, so a subscription taken before start() still sees the fetch land. --- .../app/messenger/internal/ChatViewModel.kt | 4 + .../chat/reactions/ReactorsPrefetchCache.kt | 76 +++++++++---------- .../reactions/ReactorsPrefetchCacheTest.kt | 37 ++++++--- 3 files changed, 66 insertions(+), 51 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 14e426631b..6a44f26d05 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -772,6 +772,10 @@ internal class ChatViewModel @Inject constructor( fun loadMoreReactors(messageId: Long) = reactorsPrefetchCache.loadMoreIfNeeded(messageId) + /** [messageId]'s pills, live — the reactors sheet's summary row (decision 4: title + pills). */ + fun reactionPills(messageId: Long): Flow> = + reactionOverlay.map { it[messageId]?.pills.orEmpty() }.distinctUntilChanged() + /** A reactors-sheet row's resolved identity: the name to show (decision 4's precedence, * "You" for the viewer), and the profile to draw an avatar from when one is known. */ data class ReactorDisplay(val name: String, val profile: UserProfile?) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactorsPrefetchCache.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactorsPrefetchCache.kt index 0e4f659616..3490a0c8de 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactorsPrefetchCache.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactorsPrefetchCache.kt @@ -3,45 +3,46 @@ package com.flipcash.shared.chat.reactions import com.flipcash.services.models.PagingToken import com.flipcash.services.repository.ReactorsPage import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch /** * Owns one [ReactorsListModel] per messageId, keyed so the reactors sheet and whatever started the - * fetch read the same instance rather than each building their own. + * fetch read the same rows/loading state rather than each building their own. * * Loading has to start on the pill long-press (decision: `OpenReactors` fires the fetch), not on - * the sheet's first composition — the sheet is a fresh Compose tree on every push, and a fetch - * gated on its `LaunchedEffect` would show a load on the same tap it could otherwise be racing. - * [start] is called from `ChatViewModel`'s `OpenReactors` handler, one step before the navigator - * pushes `ChatStep.Reactors`, and the sheet then only *reads* [rows]/[loading] for the id it opened - * on rather than kicking off its own load. [start] is idempotent per messageId so re-opening an - * already-fetched sheet doesn't refetch from scratch. + * the reactors sheet's first composition — the sheet is a fresh Compose tree on every push, and a + * fetch gated on its `LaunchedEffect` would race the same tap that opened it. [start] is called + * from `ChatViewModel`'s `OpenReactors` handler, one step before the navigator pushes + * `ChatStep.Reactors`; [rows]/[loading] are backed by one shared `StateFlow` each (keyed by + * messageId), rather than a per-messageId flow instance created inside [start] — a composition that + * calls [rows] *before* [start] has run still sees the update once it does, because it collects a + * `.map` over the same always-live backing flow rather than a separate object [start] would + * otherwise swap in. */ class ReactorsPrefetchCache( private val scope: CoroutineScope, private val fetchPage: suspend (messageId: Long, emoji: String, token: PagingToken?) -> Result, ) { - private class Holder(val model: ReactorsListModel) { - val rows = MutableStateFlow>(emptyList()) - val loading = MutableStateFlow(true) - } - - private val cache = mutableMapOf() + private val models = mutableMapOf() + private val allRows = MutableStateFlow>>(emptyMap()) + private val allLoading = MutableStateFlow>(emptyMap()) - /** The merged rows for [messageId], or an always-empty flow before [start] has been called. */ - fun rows(messageId: Long): StateFlow> = - cache[messageId]?.rows?.asStateFlow() ?: EMPTY_ROWS + /** The merged rows for [messageId], empty before [start] has produced a first page. */ + fun rows(messageId: Long): Flow> = + allRows.map { it[messageId].orEmpty() }.distinctUntilChanged() /** Whether a page is in flight for [messageId]. */ - fun loading(messageId: Long): StateFlow = - cache[messageId]?.loading?.asStateFlow() ?: NOT_LOADING + fun loading(messageId: Long): Flow = + allLoading.map { it[messageId] == true }.distinctUntilChanged() /** True while at least one of [messageId]'s emojis still has an unfetched page. */ - fun hasMore(messageId: Long): Boolean = cache[messageId]?.model?.hasMore ?: false + fun hasMore(messageId: Long): Boolean = models[messageId]?.hasMore ?: false /** * Starts the first round of pages for [messageId]'s [emojis] if nothing has started yet. @@ -50,35 +51,32 @@ class ReactorsPrefetchCache( * fetch/rows rather than starting over. */ fun start(messageId: Long, emojis: List) { - if (emojis.isEmpty() || cache.containsKey(messageId)) return - val holder = Holder(ReactorsListModel(emojis) { emoji, token -> fetchPage(messageId, emoji, token) }) - cache[messageId] = holder - loadMore(messageId, holder) + if (emojis.isEmpty() || models.containsKey(messageId)) return + models[messageId] = ReactorsListModel(emojis) { emoji, token -> fetchPage(messageId, emoji, token) } + loadMore(messageId) } /** Fetches the next page for every emoji of [messageId] that still has more, once no load is already in flight. */ fun loadMoreIfNeeded(messageId: Long) { - val holder = cache[messageId] ?: return - if (holder.loading.value || !holder.model.hasMore) return - loadMore(messageId, holder) + val model = models[messageId] ?: return + if (allLoading.value[messageId] == true || !model.hasMore) return + loadMore(messageId) } - private fun loadMore(messageId: Long, holder: Holder) { - holder.loading.value = true + private fun loadMore(messageId: Long) { + allLoading.update { it + (messageId to true) } scope.launch { - holder.model.loadMoreIfNeeded() - holder.rows.value = holder.model.rows - holder.loading.value = false + val model = models.getValue(messageId) + model.loadMoreIfNeeded() + allRows.update { it + (messageId to model.rows) } + allLoading.update { it + (messageId to false) } } } /** Drops [messageId]'s cached fetch/rows — for a message that leaves the transcript (deleted, etc). */ fun clear(messageId: Long) { - cache.remove(messageId) - } - - private companion object { - val EMPTY_ROWS = MutableStateFlow>(emptyList()).asStateFlow() - val NOT_LOADING = MutableStateFlow(false).asStateFlow() + models.remove(messageId) + allRows.update { it - messageId } + allLoading.update { it - messageId } } } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/reactions/ReactorsPrefetchCacheTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/reactions/ReactorsPrefetchCacheTest.kt index 6da28ef7d1..9b0ef49832 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/reactions/ReactorsPrefetchCacheTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/reactions/ReactorsPrefetchCacheTest.kt @@ -4,11 +4,11 @@ import com.flipcash.services.models.PagingToken import com.flipcash.services.models.chat.Reactor import com.flipcash.services.repository.ReactorsPage import com.getcode.opencode.model.core.ID +import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertTrue import kotlin.time.Instant private fun id(byte: Int): ID = listOf(byte.toByte()) @@ -26,11 +26,9 @@ class ReactorsPrefetchCacheTest { } // OpenReactors calls start() synchronously, one step before the navigator pushes the sheet - // (see ChatViewModel's OpenReactors handler) — so the fetch is in flight by the time a - // sheet composes and reads rows(), rather than starting from the sheet's own LaunchedEffect. + // (see ChatViewModel's OpenReactors handler) — so by the time a sheet would compose and + // read rows(), the fetch is already in flight. cache.start(messageId = 1L, emojis = listOf("👍", "🔥")) - assertTrue(cache.loading(1L).value) // in flight immediately, before any dispatcher runs it - advanceUntilIdle() assertEquals(setOf(1L to "👍", 1L to "🔥"), calls.toSet()) @@ -40,17 +38,34 @@ class ReactorsPrefetchCacheTest { fun `rows publish once the fetch completes`() = runTest { val alice = id(2) val reactedAt = Instant.fromEpochSeconds(1_000) - val cache = ReactorsPrefetchCache(scope = this) { _, emoji, _ -> + val cache = ReactorsPrefetchCache(scope = this) { _, _, _ -> Result.success(ReactorsPage(reactors = listOf(reactor(alice, reactedAt)), hasMore = false)) } cache.start(messageId = 1L, emojis = listOf("👍")) - assertTrue(cache.loading(1L).value) + advanceUntilIdle() + + assertEquals(false, cache.loading(1L).first()) + assertEquals(listOf(alice), cache.rows(1L).first().map { it.userId }) + } + @Test + fun `a rows subscriber that reads before start still sees the fetch complete`() = runTest { + val alice = id(2) + val reactedAt = Instant.fromEpochSeconds(1_000) + val cache = ReactorsPrefetchCache(scope = this) { _, _, _ -> + Result.success(ReactorsPage(reactors = listOf(reactor(alice, reactedAt)), hasMore = false)) + } + + // The sheet's own composition can read rows()/loading() before ChatViewModel's OpenReactors + // handler has run start() — both derive from the same backing StateFlow, so a flow obtained + // now still reflects the fetch once it lands, rather than being a stale placeholder. + val rowsFlow = cache.rows(1L) + + cache.start(messageId = 1L, emojis = listOf("👍")) advanceUntilIdle() - assertFalse(cache.loading(1L).value) - assertEquals(listOf(alice), cache.rows(1L).value.map { it.userId }) + assertEquals(listOf(alice), rowsFlow.first().map { it.userId }) } @Test @@ -81,8 +96,6 @@ class ReactorsPrefetchCacheTest { advanceUntilIdle() assertEquals(0, callCount) - assertEquals(emptyList(), cache.rows(1L).value) + assertEquals(emptyList(), cache.rows(1L).first()) } } - -private fun assertFalse(actual: Boolean) = assertEquals(false, actual) From f0b56763803a6a2ab0db61c349b65d773f34444c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 18:04:23 -0400 Subject: [PATCH 33/58] feat(chat): build the reactors sheet UI --- .../core/src/main/res/values/strings.xml | 3 + .../app/messenger/internal/ChatViewModel.kt | 4 +- .../internal/screens/ReactorsSheet.kt | 292 ++++++++++++++++++ 3 files changed, 297 insertions(+), 2 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactorsSheet.kt diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 8b7d51be8a..5ac6522054 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1006,6 +1006,9 @@ Message Couldn\'t add reaction This message has the maximum number of reactions + %1$d Reactions + Reactions + Search Emoji Unknown Contact Allow Full Contact Access diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 6a44f26d05..0b0b58c014 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -764,9 +764,9 @@ internal class ChatViewModel @Inject constructor( chatCoordinator.getReactorsPage(chatId, messageId, emoji, token) } - fun reactorsRows(messageId: Long): StateFlow> = reactorsPrefetchCache.rows(messageId) + fun reactorsRows(messageId: Long): Flow> = reactorsPrefetchCache.rows(messageId) - fun reactorsLoading(messageId: Long): StateFlow = reactorsPrefetchCache.loading(messageId) + fun reactorsLoading(messageId: Long): Flow = reactorsPrefetchCache.loading(messageId) fun reactorsHasMore(messageId: Long): Boolean = reactorsPrefetchCache.hasMore(messageId) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactorsSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactorsSheet.kt new file mode 100644 index 0000000000..d305c9dcca --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactorsSheet.kt @@ -0,0 +1,292 @@ +package com.flipcash.app.messenger.internal.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.messenger.internal.ChatViewModel +import com.flipcash.features.messenger.R +import com.flipcash.services.models.handle +import com.flipcash.shared.chat.reactions.ReactionPill +import com.flipcash.shared.chat.reactions.ReactorsListModel +import com.flipcash.shared.common.ui.ContactAvatar +import com.getcode.opencode.model.core.ID +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarDefaults +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.utils.AllowSheetExpansionWhenScrollable +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged + +/** + * Who reacted, and with what (decision 4): one row per person, every emoji they used, no filters. + * Opens unscrolled with nothing focused — there is no search here, unlike the picker. + * + * @param resolveDisplay per-row identity, re-subscribed for the row's lifetime in composition — + * see [ChatViewModel.reactorDisplay] for the source precedence and its live re-resolution as a + * member roster or a server fetch lands. + * @param hasMore read live rather than passed as a snapshot: the last row's visibility effect below + * asks it fresh each time it fires. + */ +@Composable +internal fun ReactorsSheet( + pills: List, + rows: List, + loading: Boolean, + hasMore: () -> Boolean, + resolveDisplay: (ID) -> Flow, + onLoadMore: () -> Unit, + onOpenProfile: (ID) -> Unit, + onDismiss: () -> Unit, +) { + val listState = rememberLazyListState() + AllowSheetExpansionWhenScrollable(listState) + + val totalCount = remember(pills) { pills.sumOf { it.count } } + + Column(modifier = Modifier.fillMaxWidth()) { + AppBarWithTitle( + title = if (totalCount > 0) { + stringResource(R.string.title_reactionsCount, totalCount) + } else { + stringResource(R.string.title_reactorsSheet) + }, + titleAlignment = Alignment.CenterHorizontally, + endContent = { AppBarDefaults.Close(onClick = onDismiss) }, + ) + + if (pills.isNotEmpty()) { + SummaryPillRow(pills = pills) + } + + // Placeholders only if nothing has rendered 300ms in — the first page usually lands + // faster than that, and this keeps a fast fetch from ever flashing skeleton rows. + val showPlaceholders by produceState(initialValue = false, rows, loading) { + value = false + if (rows.isEmpty() && loading) { + delay(300) + value = rows.isEmpty() + } + } + + LoadMoreOnLastRowVisible(listState, rows, hasMore, onLoadMore) + + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 480.dp) + .navigationBarsPadding(), + contentPadding = PaddingValues( + horizontal = CodeTheme.dimens.inset, + vertical = CodeTheme.dimens.grid.x3, + ), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + if (showPlaceholders) { + // min(max pill count, 5) placeholder rows. + val count = (pills.maxOfOrNull { it.count } ?: 1L).coerceAtMost(5L).toInt().coerceAtLeast(1) + items(count) { ReactorRowPlaceholder() } + } else { + items(rows, key = { it.userId.joinToString(",") }) { row -> + ReactorRow( + userId = row.userId, + emojis = row.emojis, + resolveDisplay = resolveDisplay, + onClick = { onOpenProfile(row.userId) }, + ) + } + } + } + } +} + +@Composable +private fun LoadMoreOnLastRowVisible( + listState: LazyListState, + rows: List, + hasMore: () -> Boolean, + onLoadMore: () -> Unit, +) { + LaunchedEffect(listState, rows) { + if (rows.isEmpty()) return@LaunchedEffect + snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index } + .distinctUntilChanged() + .collect { lastVisibleIndex -> + if (lastVisibleIndex != null && lastVisibleIndex >= rows.lastIndex && hasMore()) { + onLoadMore() + } + } + } +} + +/** The non-interactive summary row: every pill from the pill row, unclickable here. */ +@Composable +private fun SummaryPillRow(pills: List) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = CodeTheme.dimens.inset, vertical = CodeTheme.dimens.grid.x2), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + for (pill in pills) { + Box( + modifier = Modifier + .height(34.dp) + .background(Color.Black.copy(alpha = 0.3f), RoundedCornerShape(percent = 50)) + .padding(horizontal = CodeTheme.dimens.grid.x3), + contentAlignment = Alignment.Center, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(text = pill.emoji, fontSize = 17.sp) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = pill.count.toString(), + fontSize = 13.sp, + color = Color.White.copy(alpha = 0.5f), + ) + } + } + } + } +} + +/** One reactor: avatar, name/handle, and every emoji they reacted with. */ +@Composable +private fun ReactorRow( + userId: ID, + emojis: List, + resolveDisplay: (ID) -> Flow, + onClick: () -> Unit, +) { + val displayFlow = remember(userId, resolveDisplay) { resolveDisplay(userId) } + val display by displayFlow.collectAsStateWithLifecycle(initialValue = null) + + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(CodeTheme.dimens.grid.x2)) + .clickable(enabled = display != null, onClick = onClick), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + val profile = display?.profile + if (profile != null) { + ContactAvatar(userProfile = profile, modifier = Modifier.size(48.dp).clip(CircleShape)) + } else { + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(Brush.linearGradient(CodeTheme.colors.contactAvatar.colors)), + ) + } + + Column(modifier = Modifier.weight(1f)) { + Text( + text = display?.name ?: "", + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val handle = profile?.handle + if (!handle.isNullOrBlank()) { + Text( + text = handle, + fontSize = 13.sp, + color = CodeTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + // Horizontal scroll with edge fades only when it overflows its min width — every row draws + // at least 140.dp so a one-emoji row doesn't shrink to a sliver next to a five-emoji one. + val scrollState = rememberScrollState() + Row( + modifier = Modifier + .widthIn(min = 140.dp) + .horizontalScroll(scrollState), + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + for (emoji in emojis) { + Text(text = emoji, fontSize = 30.sp) + } + } + } +} + +@Composable +private fun ReactorRowPlaceholder() { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(CodeTheme.colors.divider), + ) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Box( + modifier = Modifier + .fillMaxWidth(0.4f) + .height(14.dp) + .clip(RoundedCornerShape(4.dp)) + .background(CodeTheme.colors.divider), + ) + Box( + modifier = Modifier + .fillMaxWidth(0.25f) + .height(11.dp) + .clip(RoundedCornerShape(4.dp)) + .background(CodeTheme.colors.divider), + ) + } + } +} From 6c0cbf5cdab84570e5010757d4e4b2ed857dad0a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 18:06:41 -0400 Subject: [PATCH 34/58] feat(chat): build the reaction picker sheet UI --- .../app/messenger/internal/ChatViewModel.kt | 18 ++ .../internal/screens/ReactionPickerSheet.kt | 196 ++++++++++++++++++ 2 files changed, 214 insertions(+) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactionPickerSheet.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 0b0b58c014..41a613a102 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -99,6 +99,7 @@ import com.flipcash.shared.chat.reactions.ReactorsPrefetchCache import com.flipcash.shared.chat.reactions.SelfReaction import com.getcode.libs.emojis.reactions.EmojiCatalogLoader import com.getcode.libs.emojis.reactions.EmojiDrawability +import com.getcode.libs.emojis.reactions.EmojiPickerModel import com.getcode.libs.emojis.reactions.RecentReactionsStore import com.flipcash.shared.chat.canReact import com.flipcash.shared.chat.resolveCapabilities @@ -776,6 +777,23 @@ internal class ChatViewModel @Inject constructor( fun reactionPills(messageId: Long): Flow> = reactionOverlay.map { it[messageId]?.pills.orEmpty() }.distinctUntilChanged() + /** + * The full picker's sections for [query] (recents row + one section per catalog category, or a + * single search-results section — see [EmojiPickerModel.sections]), recomposed each time + * [query] changes. Loads the catalog once per call rather than caching it on the view model, + * matching how [emojiCatalogLoader] is already used for the quick strip above — the loader + * itself caches the parsed file, so this is cheap after the first call. + */ + suspend fun emojiPickerSections(query: String): List { + val catalog = emojiCatalogLoader.load() + val undrawable = catalog.entries + .map { it.emoji } + .filterNot { EmojiDrawability.isDrawable(it) } + .toSet() + val recents = recentReactionsStore.rank(undrawable = undrawable) + return EmojiPickerModel.sections(catalog = catalog, undrawable = undrawable, recents = recents, query = query) + } + /** A reactors-sheet row's resolved identity: the name to show (decision 4's precedence, * "You" for the viewer), and the profile to draw an avatar from when one is known. */ data class ReactorDisplay(val name: String, val profile: UserProfile?) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactionPickerSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactionPickerSheet.kt new file mode 100644 index 0000000000..94b6d01d50 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactionPickerSheet.kt @@ -0,0 +1,196 @@ +package com.flipcash.app.messenger.internal.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.compose.material.Icon +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Cancel +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.flipcash.app.messenger.internal.ChatViewModel +import com.flipcash.features.messenger.R +import com.getcode.libs.emojis.reactions.EmojiPickerModel +import com.getcode.theme.CodeTheme +import com.getcode.theme.inputColors +import com.getcode.ui.components.AppBarDefaults +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.components.TextInput +import com.getcode.ui.core.unboundedClickable +import com.getcode.ui.utils.AllowSheetExpansionWhenScrollable +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged + +/** + * The full emoji picker (decision 5: opens as `ChatStep.ReactionPicker`, a [com.getcode.ui.navigation.HalfSheet]). + * A search field (not auto-focused — iOS's picker doesn't auto-focus its search field either), + * a Frequently Used row, then every catalog category, all through one scrollable grid so + * [AllowSheetExpansionWhenScrollable] sees a single scroll container. + */ +@Composable +internal fun ReactionPickerSheet( + loadSections: suspend (query: String) -> List, + onSelected: (String) -> Unit, + onDismiss: () -> Unit, +) { + val gridState = rememberLazyListState() + AllowSheetExpansionWhenScrollable(gridState) + + val searchState = remember { TextFieldState() } + + Column(modifier = Modifier.fillMaxWidth()) { + AppBarWithTitle( + title = stringResource(R.string.title_reactorsSheet), + titleAlignment = Alignment.CenterHorizontally, + endContent = { AppBarDefaults.Close(onClick = onDismiss) }, + ) + + SearchField( + state = searchState, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(bottom = CodeTheme.dimens.grid.x2), + ) + + val query by produceState(initialValue = "") { + snapshotFlow { searchState.text.toString() } + .distinctUntilChanged() + .debounce(150) + .collect { value = it } + } + + val sections by produceState(initialValue = emptyList(), query, loadSections) { + value = loadSections(query) + } + + EmojiGrid( + sections = sections, + gridState = gridState, + onSelected = onSelected, + ) + } +} + +private const val COLUMNS = 7 + +@Composable +private fun EmojiGrid( + sections: List, + gridState: LazyListState, + onSelected: (String) -> Unit, +) { + // A LazyColumn of hand-chunked rows rather than LazyVerticalGrid: AllowSheetExpansionWhenScrollable + // only reads a LazyListState, and one flat list keeps every section's header + rows in the same + // scroll container it watches. + LazyColumn( + state = gridState, + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 480.dp) + .navigationBarsPadding(), + contentPadding = PaddingValues(horizontal = CodeTheme.dimens.inset, vertical = CodeTheme.dimens.grid.x2), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), + ) { + for (section in sections) { + item(key = "header-${section.id}") { + Text( + text = section.title, + fontSize = 13.sp, + color = CodeTheme.colors.textSecondary, + modifier = Modifier.padding( + top = CodeTheme.dimens.grid.x2, + bottom = CodeTheme.dimens.grid.x1, + ), + ) + } + val rows = section.entries.chunked(COLUMNS) + items(rows, key = { row -> "${section.id}-${row.first().emoji}" }) { row -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), + ) { + for (entry in row) { + Text( + text = entry.emoji, + fontSize = 28.sp, + modifier = Modifier + .size(44.dp) + .wrapContentWidth(Alignment.CenterHorizontally) + .unboundedClickable { onSelected(entry.emoji) }, + ) + } + if (row.size < COLUMNS) { + Box(modifier = Modifier.width((44 * (COLUMNS - row.size)).dp)) + } + } + } + } + } +} + +@Composable +private fun SearchField(state: TextFieldState, modifier: Modifier = Modifier) { + TextInput( + modifier = modifier, + shape = CodeTheme.shapes.medium, + state = state, + placeholder = stringResource(R.string.hint_searchEmoji), + minHeight = CodeTheme.dimens.grid.x8, + contentPadding = PaddingValues( + start = 8.dp + CodeTheme.dimens.staticGrid.x2, + top = 8.dp, + end = 8.dp + CodeTheme.dimens.staticGrid.x2, + bottom = 8.dp, + ), + maxLines = 1, + colors = inputColors( + backgroundColor = CodeTheme.colors.surface, + textColor = CodeTheme.colors.textMain, + cursorColor = CodeTheme.colors.textMain, + placeholderColor = CodeTheme.colors.textSecondary, + ), + trailingIcon = state.text.takeIf { it.isNotBlank() }?.let { + { + Icon( + imageVector = Icons.Filled.Cancel, + contentDescription = "", + tint = CodeTheme.colors.textSecondary, + modifier = Modifier + .padding(end = CodeTheme.dimens.grid.x2) + .wrapContentWidth() + .size(20.dp) + .unboundedClickable { state.clearText() }, + ) + } + }, + ) +} From e410572863f958536bdcf4729cac1e62c42c884b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 18:08:24 -0400 Subject: [PATCH 35/58] feat(chat): wire the picker and reactors sheets into ChatFlowScreen --- .../flipcash/app/messenger/ChatFlowScreen.kt | 87 +++++++++++++------ 1 file changed, 60 insertions(+), 27 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index a48a754e1c..87f878fa87 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -1,18 +1,14 @@ package com.flipcash.app.messenger import android.os.Parcelable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavKey @@ -29,6 +25,8 @@ import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.app.messenger.internal.StartSendCashOnceReady import com.flipcash.app.messenger.internal.screens.GroupInviteSheet import com.flipcash.app.messenger.internal.screens.MessengerScreen +import com.flipcash.app.messenger.internal.screens.ReactionPickerSheet +import com.flipcash.app.messenger.internal.screens.ReactorsSheet import com.flipcash.app.messenger.internal.screens.cash.ChatAmountEntryContent import com.flipcash.app.messenger.internal.screens.cash.ChatInitPaymentSheet import com.flipcash.app.messenger.internal.screens.profile.ChatProfileScreen @@ -56,6 +54,7 @@ import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch @Composable fun ChatFlowScreen( @@ -122,13 +121,11 @@ private fun chatEntryProvider( FlowEditGroupPictureScreen() } - // Placeholder entries: content lands in a follow-up. Registering the step now is what lets - // ChatViewModel's OpenReactionPicker/OpenReactors dispatch a real navigation. - annotatedEntry { - FlowReactionPickerScreen() + annotatedEntry { step -> + FlowReactionPickerScreen(step.messageId) } - annotatedEntry { - FlowReactorsScreen() + annotatedEntry { step -> + FlowReactorsScreen(step.messageId) } } @@ -333,26 +330,62 @@ private fun FlowEditGroupPictureScreen() { EditGroupPictureScreen(flowSharedViewModel()) } -/** - * Placeholder for the full emoji picker sheet — content is a separate change. This exists only so - * [ChatStep.ReactionPicker] is a real, navigable destination for - * `ChatViewModel.Event.OpenReactionPicker` to reach; [flowSharedViewModel] keeps it on the same - * conversation view model the picker will read from once it has content. - */ +/** The full emoji picker for [messageId] — a tap on any emoji toggles it and dismisses. */ @Composable -private fun FlowReactionPickerScreen() { - flowSharedViewModel() - Box(modifier = Modifier.fillMaxWidth().height(1.dp)) +private fun FlowReactionPickerScreen(messageId: Long) { + val viewModel = flowSharedViewModel() + val dismissSheet = LocalBottomSheetDismissDispatcher.current + + ReactionPickerSheet( + loadSections = { query -> viewModel.emojiPickerSections(query) }, + onSelected = { emoji -> + viewModel.dispatchEvent( + ChatViewModel.Event.ToggleReaction(messageId, emoji, clearsSelection = true) + ) + dismissSheet() + }, + onDismiss = dismissSheet, + ) } /** - * Placeholder for the reactors sheet — content is a separate change. This exists only so - * [ChatStep.Reactors] is a real, navigable destination for `ChatViewModel.Event.OpenReactors` to - * reach; [flowSharedViewModel] keeps it on the same conversation view model the sheet will read - * from once it has content. + * Who reacted to [messageId], and with what. Tapping a row dismisses this sheet first, then opens + * the reactor's profile — [ChatViewModel.reactorParticipant] resolves a [ChatParticipant] even for + * a reactor absent from `senderProfiles`. */ @Composable -private fun FlowReactorsScreen() { - flowSharedViewModel() - Box(modifier = Modifier.fillMaxWidth().height(1.dp)) +private fun FlowReactorsScreen(messageId: Long) { + val viewModel = flowSharedViewModel() + val navigator = LocalCodeNavigator.current + val dismissSheet = LocalBottomSheetDismissDispatcher.current + val coroutineScope = rememberCoroutineScope() + + val pills by remember(viewModel, messageId) { viewModel.reactionPills(messageId) } + .collectAsStateWithLifecycle(initialValue = emptyList()) + val rows by remember(viewModel, messageId) { viewModel.reactorsRows(messageId) } + .collectAsStateWithLifecycle(initialValue = emptyList()) + val loading by remember(viewModel, messageId) { viewModel.reactorsLoading(messageId) } + .collectAsStateWithLifecycle(initialValue = true) + + ReactorsSheet( + pills = pills, + rows = rows, + loading = loading, + hasMore = { viewModel.reactorsHasMore(messageId) }, + resolveDisplay = { userId -> viewModel.reactorDisplay(userId) }, + onLoadMore = { viewModel.loadMoreReactors(messageId) }, + onOpenProfile = { userId -> + // Dismiss first (decision: profile opens after the sheet closes), then resolve the + // participant — a row is only tappable once reactorDisplay has already resolved it, so + // this is a re-read of state already on screen rather than a fresh network round trip + // in the common case; reactorParticipant only reaches further (member roster, cached + // profile store) for a reactor absent from senderProfiles. + dismissSheet() + coroutineScope.launch { + val participant = viewModel.reactorParticipant(userId) ?: return@launch + navigator.push(ChatStep.Profile(contact = participant)) + } + }, + onDismiss = dismissSheet, + ) } From 26480e0cb970aa5640422d0f2778ba78b6e01b5f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 18:10:51 -0400 Subject: [PATCH 36/58] test(messenger): pass userProfileDataSource mock to ChatViewModel in analytics tests --- .../flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt | 3 +++ .../app/messenger/internal/ChatSendFailureAnalyticsTest.kt | 3 +++ 2 files changed, 6 insertions(+) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt index 1eab7ee7d2..ffc4aa13ad 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt @@ -8,6 +8,7 @@ import com.flipcash.analytics.PropertyValue import com.flipcash.analytics.State as AnalyticsState import com.flipcash.app.analytics.RecordingAnalytics import com.flipcash.app.contacts.ContactCoordinator +import com.flipcash.app.persistence.sources.UserProfileDataSource import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.funding.PurchaseMethodController import com.flipcash.app.messenger.internal.link.LinkCardClassifier @@ -96,6 +97,7 @@ class ChatGroupAnalyticsTest { private val chatDraftStore = mockk(relaxed = true) private val recentReactionsStore = mockk(relaxed = true) private val emojiCatalogLoader = mockk(relaxed = true) + private val userProfileDataSource = mockk(relaxed = true) private val chatId = ChatId(UUID.randomUUID().bytes) private val mint = Mint("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaaaaaaaaaaa") @@ -148,6 +150,7 @@ class ChatGroupAnalyticsTest { recentReactionsStore = recentReactionsStore, toastController = mockk(relaxed = true), emojiCatalogLoader = emojiCatalogLoader, + userProfileDataSource = userProfileDataSource, dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), ) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt index 9b070f2e3c..5c0d998d51 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt @@ -17,6 +17,7 @@ import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.libs.coroutines.TestDispatcherProvider import com.flipcash.services.models.UserProfile +import com.flipcash.app.persistence.sources.UserProfileDataSource import com.flipcash.services.models.chat.ChatId import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.ChatCoordinator @@ -91,6 +92,7 @@ class ChatSendFailureAnalyticsTest { private val chatDraftStore = mockk(relaxed = true) private val recentReactionsStore = mockk(relaxed = true) private val emojiCatalogLoader = mockk(relaxed = true) + private val userProfileDataSource = mockk(relaxed = true) private val token = mockk(relaxed = true) private val amount = Fiat(5.0) @@ -140,6 +142,7 @@ class ChatSendFailureAnalyticsTest { recentReactionsStore = recentReactionsStore, toastController = mockk(relaxed = true), emojiCatalogLoader = emojiCatalogLoader, + userProfileDataSource = userProfileDataSource, dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), ) From faecd86a92ea1094383b124145452b8628823b26 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 18:12:32 -0400 Subject: [PATCH 37/58] test(emojis): run the drawability vectors on a device Robolectric's Paint has no emoji font, so the unit test could only check that EmojiDrawability ran. The instrumented test asserts each #17 drawability vector against the device font, with a verbatim copy of reactions.json in androidTest assets. --- libs/emojis/build.gradle.kts | 4 + .../src/androidTest/assets/reactions.json | 1766 +++++++++++++++++ .../reactions/EmojiDrawabilityVectorTest.kt | 32 + .../reactions/RecentReactionsVectorTest.kt | 28 +- 4 files changed, 1805 insertions(+), 25 deletions(-) create mode 100644 libs/emojis/src/androidTest/assets/reactions.json create mode 100644 libs/emojis/src/androidTest/java/com/getcode/libs/emojis/reactions/EmojiDrawabilityVectorTest.kt diff --git a/libs/emojis/build.gradle.kts b/libs/emojis/build.gradle.kts index dbad6a2a63..6805d01557 100644 --- a/libs/emojis/build.gradle.kts +++ b/libs/emojis/build.gradle.kts @@ -15,6 +15,10 @@ dependencies { testImplementation(kotlin("test")) testImplementation(libs.bundles.unit.testing) testImplementation(libs.robolectric) + + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.junit) + androidTestImplementation(libs.androidx.test.runner) } // Define the task to fetch and generate emoji data diff --git a/libs/emojis/src/androidTest/assets/reactions.json b/libs/emojis/src/androidTest/assets/reactions.json new file mode 100644 index 0000000000..b8b86ea67d --- /dev/null +++ b/libs/emojis/src/androidTest/assets/reactions.json @@ -0,0 +1,1766 @@ +{ + "defaults": [ + "❤️", + "👍", + "😂", + "😮", + "😢", + "🔥" + ], + "merge": [ + { + "name": "summary-renders", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 2, + "self": false, + "version": 4 + }, + { + "emoji": "🔥", + "count": 1, + "self": true, + "version": 1 + } + ] + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 2, + "selfReacted": false, + "pending": false + }, + { + "emoji": "🔥", + "count": 1, + "selfReacted": true, + "pending": false + } + ], + "calls": [], + "errors": [] + }, + "note": "A summary alone. Counts as given; self from self_reactor." + }, + { + "name": "stale-update-dropped", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 3, + "self": false, + "version": 7 + } + ] + }, + { + "op": "update", + "emoji": "👍", + "actor": "ana", + "action": "REMOVED", + "count": 2, + "version": 6 + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 3, + "selfReacted": false, + "pending": false + } + ], + "calls": [], + "errors": [] + }, + "note": "Version 6 is below the held 7, so it is an update the summary already folded in." + }, + { + "name": "equal-version-dropped", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 3, + "self": false, + "version": 7 + } + ] + }, + { + "op": "update", + "emoji": "👍", + "actor": "ana", + "action": "REMOVED", + "count": 2, + "version": 7 + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 3, + "selfReacted": false, + "pending": false + } + ], + "calls": [], + "errors": [] + }, + "note": "Accept strictly greater. The same version is the same state." + }, + { + "name": "newer-update-applied", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 3, + "self": false, + "version": 7 + } + ] + }, + { + "op": "update", + "emoji": "👍", + "actor": "ana", + "action": "ADDED", + "count": 4, + "version": 8 + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 4, + "selfReacted": false, + "pending": false + } + ], + "calls": [], + "errors": [] + }, + "note": "Count comes from the update, not from adding one: an update is state, not a delta." + }, + { + "name": "update-to-zero-removes-pill", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 3 + } + ] + }, + { + "op": "update", + "emoji": "👍", + "actor": "ana", + "action": "REMOVED", + "count": 0, + "version": 4 + } + ], + "expect": { + "pills": [], + "calls": [], + "errors": [] + }, + "note": "Zero count, no pill." + }, + { + "name": "tombstone-rejects-late-add", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 3 + } + ] + }, + { + "op": "update", + "emoji": "👍", + "actor": "ana", + "action": "REMOVED", + "count": 0, + "version": 4 + }, + { + "op": "update", + "emoji": "👍", + "actor": "ana", + "action": "ADDED", + "count": 1, + "version": 3 + } + ], + "expect": { + "pills": [], + "calls": [], + "errors": [] + }, + "note": "The server keeps an emptied emoji's version, so the client must too. Without the tombstone the late ADDED at 3 would resurrect the pill." + }, + { + "name": "readd-after-empty", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 3 + } + ] + }, + { + "op": "update", + "emoji": "👍", + "actor": "ana", + "action": "REMOVED", + "count": 0, + "version": 4 + }, + { + "op": "update", + "emoji": "👍", + "actor": "ben", + "action": "ADDED", + "count": 1, + "version": 5 + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 1, + "selfReacted": false, + "pending": false + } + ], + "calls": [], + "errors": [] + }, + "note": "A re-add comes above the tombstone and is accepted." + }, + { + "name": "new-emoji-from-stream", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 1 + } + ] + }, + { + "op": "update", + "emoji": "🎉", + "actor": "ana", + "action": "ADDED", + "count": 1, + "version": 1 + } + ], + "expect": { + "pills": [ + { + "emoji": "🎉", + "count": 1, + "selfReacted": false, + "pending": false + }, + { + "emoji": "👍", + "count": 1, + "selfReacted": false, + "pending": false + } + ], + "calls": [], + "errors": [] + }, + "note": "An emoji we have never held has no watermark; any version is accepted. Equal counts fall back to UTF-8 byte order: 🎉 (F0 9F 8E 89) before 👍 (F0 9F 91 8D)." + }, + { + "name": "own-update-from-another-device", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 1 + } + ] + }, + { + "op": "update", + "emoji": "👍", + "actor": "me", + "action": "ADDED", + "count": 2, + "version": 2 + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 2, + "selfReacted": true, + "pending": false + } + ], + "calls": [], + "errors": [] + }, + "note": "Our own reaction made on another device arrives on the stream and highlights the pill." + }, + { + "name": "tap-adds-optimistically", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 1 + } + ] + }, + { + "op": "tap", + "emoji": "👍" + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 2, + "selfReacted": true, + "pending": true + } + ], + "calls": [ + { + "op": "add", + "emoji": "👍" + } + ], + "errors": [] + }, + "note": "The pill changes before the server answers, and is marked pending." + }, + { + "name": "tap-new-emoji", + "self": "me", + "steps": [ + { + "op": "tap", + "emoji": "🔥" + } + ], + "expect": { + "pills": [ + { + "emoji": "🔥", + "count": 1, + "selfReacted": true, + "pending": true + } + ], + "calls": [ + { + "op": "add", + "emoji": "🔥" + } + ], + "errors": [] + }, + "note": "Tapping an emoji the message does not have yet, from the strip or picker." + }, + { + "name": "tap-confirmed", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 1 + } + ] + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "respond", + "emoji": "👍", + "result": "OK", + "reaction": { + "count": 2, + "self": true, + "version": 2 + } + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 2, + "selfReacted": true, + "pending": false + } + ], + "calls": [ + { + "op": "add", + "emoji": "👍" + } + ], + "errors": [] + }, + "note": "The response's EmojiReaction replaces the pending state." + }, + { + "name": "tap-removes", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 2, + "self": true, + "version": 5 + } + ] + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "respond", + "emoji": "👍", + "result": "OK", + "reaction": { + "count": 1, + "self": false, + "version": 6 + } + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 1, + "selfReacted": false, + "pending": false + } + ], + "calls": [ + { + "op": "remove", + "emoji": "👍" + } + ], + "errors": [] + }, + "note": "Tapping a highlighted pill removes." + }, + { + "name": "remove-last-reaction", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": true, + "version": 5 + } + ] + }, + { + "op": "tap", + "emoji": "👍" + } + ], + "expect": { + "pills": [], + "calls": [ + { + "op": "remove", + "emoji": "👍" + } + ], + "errors": [] + }, + "note": "Removing the only reaction hides the pill immediately, before the response." + }, + { + "name": "stream-during-pending-keeps-highlight", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 1 + } + ] + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "update", + "emoji": "👍", + "actor": "ana", + "action": "ADDED", + "count": 2, + "version": 2 + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 3, + "selfReacted": true, + "pending": true + } + ], + "calls": [ + { + "op": "add", + "emoji": "👍" + } + ], + "errors": [] + }, + "note": "Someone else's reaction lands while ours is on the wire: their count shows, our highlight stays." + }, + { + "name": "own-echo-before-response", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 1 + } + ] + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "update", + "emoji": "👍", + "actor": "me", + "action": "ADDED", + "count": 2, + "version": 2 + }, + { + "op": "respond", + "emoji": "👍", + "result": "OK", + "reaction": { + "count": 2, + "self": true, + "version": 2 + } + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 2, + "selfReacted": true, + "pending": false + } + ], + "calls": [ + { + "op": "add", + "emoji": "👍" + } + ], + "errors": [] + }, + "note": "The stream can deliver our own add before the RPC returns. It must not count us twice (3), and the response at the same version is then a no-op." + }, + { + "name": "coalesce-on-off-on", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 1 + } + ] + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "tap", + "emoji": "👍" + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 2, + "selfReacted": true, + "pending": true + } + ], + "calls": [ + { + "op": "add", + "emoji": "👍" + } + ], + "errors": [] + }, + "note": "Three taps while the first add is in flight: one call. The later taps only move the displayed state." + }, + { + "name": "coalesce-on-off-then-settle", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 1 + } + ] + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "respond", + "emoji": "👍", + "result": "OK", + "reaction": { + "count": 2, + "self": true, + "version": 2 + } + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 1, + "selfReacted": false, + "pending": true + } + ], + "calls": [ + { + "op": "add", + "emoji": "👍" + }, + { + "op": "remove", + "emoji": "👍" + } + ], + "errors": [] + }, + "note": "On, then off, while the add is in flight. The add lands; the final state (off) differs from the server's (on), so a remove follows, and the pill shows the off state as pending until it returns." + }, + { + "name": "coalesce-on-off-on-settle", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 1 + } + ] + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "respond", + "emoji": "👍", + "result": "OK", + "reaction": { + "count": 2, + "self": true, + "version": 2 + } + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 2, + "selfReacted": true, + "pending": false + } + ], + "calls": [ + { + "op": "add", + "emoji": "👍" + } + ], + "errors": [] + }, + "note": "On, off, on. The add lands and the final state already matches, so nothing more is sent." + }, + { + "name": "network-failure-rolls-back", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 1 + } + ] + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "respond", + "emoji": "👍", + "result": "NETWORK" + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 1, + "selfReacted": false, + "pending": false + } + ], + "calls": [ + { + "op": "add", + "emoji": "👍" + } + ], + "errors": [ + "reactionFailed" + ] + }, + "note": "Back to the confirmed state, with the generic error." + }, + { + "name": "denied-rolls-back", + "self": "me", + "steps": [ + { + "op": "tap", + "emoji": "🫩" + }, + { + "op": "respond", + "emoji": "🫩", + "result": "DENIED" + } + ], + "expect": { + "pills": [], + "calls": [ + { + "op": "add", + "emoji": "🫩" + } + ], + "errors": [ + "reactionFailed" + ] + }, + "note": "DENIED is shown like a network failure; the catalog should make it rare." + }, + { + "name": "too-many-types", + "self": "me", + "steps": [ + { + "op": "tap", + "emoji": "🦖" + }, + { + "op": "respond", + "emoji": "🦖", + "result": "TOO_MANY_REACTION_TYPES" + } + ], + "expect": { + "pills": [], + "calls": [ + { + "op": "add", + "emoji": "🦖" + } + ], + "errors": [ + "tooManyReactionTypes" + ] + }, + "note": "The 21st distinct emoji on a message." + }, + { + "name": "message-not-found-is-silent", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 1 + } + ] + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "respond", + "emoji": "👍", + "result": "MESSAGE_NOT_FOUND" + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 1, + "selfReacted": false, + "pending": false + } + ], + "calls": [ + { + "op": "add", + "emoji": "👍" + } + ], + "errors": [] + }, + "note": "The message was deleted; the stream is removing it, so no error is shown." + }, + { + "name": "cannot-react-is-silent", + "self": "me", + "steps": [ + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "respond", + "emoji": "👍", + "result": "CANNOT_REACT" + } + ], + "expect": { + "pills": [], + "calls": [ + { + "op": "add", + "emoji": "👍" + } + ], + "errors": [] + }, + "note": "The strip should not have been offered. Roll back quietly." + }, + { + "name": "failure-drops-coalesced-taps", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 1 + } + ] + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "respond", + "emoji": "👍", + "result": "NETWORK" + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 1, + "selfReacted": false, + "pending": false + } + ], + "calls": [ + { + "op": "add", + "emoji": "👍" + } + ], + "errors": [ + "reactionFailed" + ] + }, + "note": "Nothing is queued: a failure discards the taps made while it was in flight too." + }, + { + "name": "summary-omits-emptied-emoji", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 1 + }, + { + "emoji": "🔥", + "count": 2, + "self": false, + "version": 3 + } + ] + }, + { + "op": "summary", + "reactions": [ + { + "emoji": "🔥", + "count": 2, + "self": false, + "version": 3 + } + ] + } + ], + "expect": { + "pills": [ + { + "emoji": "🔥", + "count": 2, + "selfReacted": false, + "pending": false + } + ], + "calls": [], + "errors": [] + }, + "note": "A later summary without 👍 means it emptied." + }, + { + "name": "summary-keeps-pending-emoji", + "self": "me", + "steps": [ + { + "op": "tap", + "emoji": "👍" + }, + { + "op": "summary", + "reactions": [ + { + "emoji": "🔥", + "count": 1, + "self": false, + "version": 1 + } + ] + } + ], + "expect": { + "pills": [ + { + "emoji": "👍", + "count": 1, + "selfReacted": true, + "pending": true + }, + { + "emoji": "🔥", + "count": 1, + "selfReacted": false, + "pending": false + } + ], + "calls": [ + { + "op": "add", + "emoji": "👍" + } + ], + "errors": [] + }, + "note": "A summary fetched before our add landed must not wipe the pending tap." + }, + { + "name": "reactions-are-per-emoji", + "self": "me", + "steps": [ + { + "op": "summary", + "reactions": [ + { + "emoji": "👍", + "count": 1, + "self": false, + "version": 9 + }, + { + "emoji": "🔥", + "count": 1, + "self": false, + "version": 2 + } + ] + }, + { + "op": "update", + "emoji": "🔥", + "actor": "ana", + "action": "ADDED", + "count": 2, + "version": 3 + } + ], + "expect": { + "pills": [ + { + "emoji": "🔥", + "count": 2, + "selfReacted": false, + "pending": false + }, + { + "emoji": "👍", + "count": 1, + "selfReacted": false, + "pending": false + } + ], + "calls": [], + "errors": [] + }, + "note": "Versions are per (message, emoji). 🔥 at 3 is fresh even though 👍 is at 9." + } + ], + "order": [ + { + "name": "count-descending", + "pills": [ + { + "emoji": "👍", + "count": 1, + "boostTotal": 0 + }, + { + "emoji": "🔥", + "count": 3, + "boostTotal": 0 + }, + { + "emoji": "😂", + "count": 2, + "boostTotal": 0 + } + ], + "expect": [ + "🔥", + "😂", + "👍" + ], + "note": "Phase 1: every boost total is 0, so count decides." + }, + { + "name": "boost-before-count", + "pills": [ + { + "emoji": "👍", + "count": 5, + "boostTotal": 0 + }, + { + "emoji": "💯", + "count": 2, + "boostTotal": 1000 + }, + { + "emoji": "👎", + "count": 1, + "boostTotal": 500 + } + ], + "expect": [ + "💯", + "👎", + "👍" + ], + "note": "Phase 2: any boost outranks any count. The unit is opaque here, only its order matters." + }, + { + "name": "tie-on-utf8-bytes", + "pills": [ + { + "emoji": "😂", + "count": 1, + "boostTotal": 0 + }, + { + "emoji": "👍", + "count": 1, + "boostTotal": 0 + }, + { + "emoji": "❤️", + "count": 1, + "boostTotal": 0 + } + ], + "expect": [ + "❤️", + "👍", + "😂" + ], + "note": "Ties fall back to the server's order, UTF-8 byte order. ❤️ starts E2, the others F0." + }, + { + "name": "tie-utf8-not-utf16", + "pills": [ + { + "emoji": "☝🏻", + "count": 1, + "boostTotal": 0 + }, + { + "emoji": "☝️", + "count": 1, + "boostTotal": 0 + } + ], + "expect": [ + "☝️", + "☝🏻" + ], + "note": "The case that separates byte order from a platform's default string compare. Both start U+261D. Next, ☝️ has U+FE0F (UTF-8 EF B8 8F) and ☝🏻 has U+1F3FB (F0 9F 8F BB): EF < F0 in UTF-8, but in UTF-16 the surrogate D83C < FE0F, which would flip them." + } + ], + "strip": [ + { + "name": "no-reactions", + "recents": [ + "😂", + "❤️", + "🔥", + "👍", + "😭", + "✨" + ], + "selfReactions": [], + "expect": [ + { + "emoji": "😂", + "highlighted": false + }, + { + "emoji": "❤️", + "highlighted": false + }, + { + "emoji": "🔥", + "highlighted": false + }, + { + "emoji": "👍", + "highlighted": false + }, + { + "emoji": "😭", + "highlighted": false + }, + { + "emoji": "✨", + "highlighted": false + } + ], + "note": "Recents, in rank order." + }, + { + "name": "reacted-with-a-recent", + "recents": [ + "😂", + "❤️", + "🔥", + "👍", + "😭", + "✨" + ], + "selfReactions": [ + { + "emoji": "🔥", + "reactedAt": 100 + } + ], + "expect": [ + { + "emoji": "😂", + "highlighted": false + }, + { + "emoji": "❤️", + "highlighted": false + }, + { + "emoji": "🔥", + "highlighted": true + }, + { + "emoji": "👍", + "highlighted": false + }, + { + "emoji": "😭", + "highlighted": false + }, + { + "emoji": "✨", + "highlighted": false + } + ], + "note": "A reaction already in the recents is highlighted in place, not moved." + }, + { + "name": "reacted-outside-recents", + "recents": [ + "😂", + "❤️", + "🔥", + "👍", + "😭", + "✨" + ], + "selfReactions": [ + { + "emoji": "💥", + "reactedAt": 100 + } + ], + "expect": [ + { + "emoji": "😂", + "highlighted": false + }, + { + "emoji": "❤️", + "highlighted": false + }, + { + "emoji": "🔥", + "highlighted": false + }, + { + "emoji": "👍", + "highlighted": false + }, + { + "emoji": "😭", + "highlighted": false + }, + { + "emoji": "✨", + "highlighted": false + }, + { + "emoji": "💥", + "highlighted": true + } + ], + "note": "After the six, next to +. The WhatsApp screenshot in the design review." + }, + { + "name": "several-outside-newest-first", + "recents": [ + "😂", + "❤️", + "🔥", + "👍", + "😭", + "✨" + ], + "selfReactions": [ + { + "emoji": "💥", + "reactedAt": 100 + }, + { + "emoji": "🦖", + "reactedAt": 300 + }, + { + "emoji": "🫠", + "reactedAt": 200 + } + ], + "expect": [ + { + "emoji": "😂", + "highlighted": false + }, + { + "emoji": "❤️", + "highlighted": false + }, + { + "emoji": "🔥", + "highlighted": false + }, + { + "emoji": "👍", + "highlighted": false + }, + { + "emoji": "😭", + "highlighted": false + }, + { + "emoji": "✨", + "highlighted": false + }, + { + "emoji": "🦖", + "highlighted": true + }, + { + "emoji": "🫠", + "highlighted": true + }, + { + "emoji": "💥", + "highlighted": true + } + ], + "note": "Newest reaction first, so the one you just made is the one visible before scrolling." + }, + { + "name": "mixed", + "recents": [ + "😂", + "❤️", + "🔥", + "👍", + "😭", + "✨" + ], + "selfReactions": [ + { + "emoji": "❤️", + "reactedAt": 50 + }, + { + "emoji": "🦖", + "reactedAt": 60 + } + ], + "expect": [ + { + "emoji": "😂", + "highlighted": false + }, + { + "emoji": "❤️", + "highlighted": true + }, + { + "emoji": "🔥", + "highlighted": false + }, + { + "emoji": "👍", + "highlighted": false + }, + { + "emoji": "😭", + "highlighted": false + }, + { + "emoji": "✨", + "highlighted": false + }, + { + "emoji": "🦖", + "highlighted": true + } + ], + "note": "Only the reaction outside the recents is appended." + } + ], + "recents": [ + { + "name": "empty-pads-defaults", + "uses": [], + "undrawable": [], + "limit": 6, + "expect": [ + "❤️", + "👍", + "😂", + "😮", + "😢", + "🔥" + ], + "note": "Nothing used yet: the defaults from node 10286:2573, in their order." + }, + { + "name": "frequency-first", + "uses": [ + { + "emoji": "🦖", + "usedAt": 1 + }, + { + "emoji": "🦖", + "usedAt": 2 + }, + { + "emoji": "🦖", + "usedAt": 3 + }, + { + "emoji": "🫠", + "usedAt": 4 + } + ], + "undrawable": [], + "limit": 6, + "expect": [ + "🦖", + "🫠", + "❤️", + "👍", + "😂", + "😮" + ], + "note": "Most used first, then padding with the defaults." + }, + { + "name": "recency-breaks-ties", + "uses": [ + { + "emoji": "🦖", + "usedAt": 1 + }, + { + "emoji": "🫠", + "usedAt": 2 + }, + { + "emoji": "🎉", + "usedAt": 3 + } + ], + "undrawable": [], + "limit": 6, + "expect": [ + "🎉", + "🫠", + "🦖", + "❤️", + "👍", + "😂" + ], + "note": "Equal counts: most recently used first." + }, + { + "name": "padding-skips-used-defaults", + "uses": [ + { + "emoji": "👍", + "usedAt": 1 + }, + { + "emoji": "👍", + "usedAt": 2 + } + ], + "undrawable": [], + "limit": 6, + "expect": [ + "👍", + "❤️", + "😂", + "😮", + "😢", + "🔥" + ], + "note": "A default already ranked is not repeated." + }, + { + "name": "undrawable-skipped", + "uses": [ + { + "emoji": "🫩", + "usedAt": 1 + }, + { + "emoji": "🫩", + "usedAt": 2 + }, + { + "emoji": "🦖", + "usedAt": 3 + } + ], + "undrawable": [ + "🫩" + ], + "limit": 6, + "expect": [ + "🦖", + "❤️", + "👍", + "😂", + "😮", + "😢" + ], + "note": "Emoji this device cannot draw are not offered, even if used on another OS version." + }, + { + "name": "picker-row-of-seven", + "uses": [], + "undrawable": [], + "limit": 7, + "expect": [ + "❤️", + "👍", + "😂", + "😮", + "😢", + "🔥" + ], + "note": "The picker asks for 7 and there are 6 defaults, so an unused picker shows 6. Not padded further: the row is not worth an invented seventh default." + }, + { + "name": "limit-truncates", + "uses": [ + { + "emoji": "🦖", + "usedAt": 0 + }, + { + "emoji": "🫠", + "usedAt": 1 + }, + { + "emoji": "🎉", + "usedAt": 2 + }, + { + "emoji": "💥", + "usedAt": 3 + }, + { + "emoji": "✨", + "usedAt": 4 + }, + { + "emoji": "😭", + "usedAt": 5 + }, + { + "emoji": "🥺", + "usedAt": 6 + }, + { + "emoji": "🙏", + "usedAt": 7 + } + ], + "undrawable": [], + "limit": 6, + "expect": [ + "🙏", + "🥺", + "😭", + "✨", + "💥", + "🎉" + ], + "note": "All used once, so recency orders them and the oldest two drop." + } + ], + "drawability": [ + { + "emoji": "😀", + "drawable": true, + "note": "Emoji 1.0, single code point." + }, + { + "emoji": "👍🏽", + "drawable": true, + "note": "Base plus skin-tone modifier." + }, + { + "emoji": "🧑🏻‍🤝‍🧑🏿", + "drawable": true, + "note": "Mixed-skin-tone ZWJ sequence, Emoji 12.1. The case a glyph-count rule gets wrong on iOS." + }, + { + "emoji": "🐈‍🦄", + "drawable": false, + "note": "Not an RGI sequence, so no font has a ligature; it draws as two emoji." + }, + { + "emoji": "🇦🇦", + "drawable": false, + "note": "Regional indicators that are not a flag draw as two letters." + } + ] +} diff --git a/libs/emojis/src/androidTest/java/com/getcode/libs/emojis/reactions/EmojiDrawabilityVectorTest.kt b/libs/emojis/src/androidTest/java/com/getcode/libs/emojis/reactions/EmojiDrawabilityVectorTest.kt new file mode 100644 index 0000000000..5efb2e963e --- /dev/null +++ b/libs/emojis/src/androidTest/java/com/getcode/libs/emojis/reactions/EmojiDrawabilityVectorTest.kt @@ -0,0 +1,32 @@ +package com.getcode.libs.emojis.reactions + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Runs the `drawability` vectors from flipcash-client-orchestrator#17 against the device's emoji + * font. Instrumented because Robolectric's Paint has no real font to shape a ZWJ sequence with. + * + * The mixed-skin-tone vector needs Emoji 12.1, which Android ships from API 30. + */ +@RunWith(AndroidJUnit4::class) +class EmojiDrawabilityVectorTest { + + @Test + fun drawabilityVectors() { + val json = InstrumentationRegistry.getInstrumentation().context.assets + .open("reactions.json").bufferedReader().use { it.readText() } + val vectors = JSONObject(json).getJSONArray("drawability") + val failures = (0 until vectors.length()).map { vectors.getJSONObject(it) }.mapNotNull { vector -> + val emoji = vector.getString("emoji") + val expected = vector.getBoolean("drawable") + val actual = EmojiDrawability.isDrawable(emoji) + if (actual == expected) null else "$emoji (${vector.getString("note")}): expected $expected, got $actual" + } + assertEquals("drawability vectors failed:\n" + failures.joinToString("\n"), emptyList(), failures) + } +} diff --git a/libs/emojis/src/test/kotlin/com/getcode/libs/emojis/reactions/RecentReactionsVectorTest.kt b/libs/emojis/src/test/kotlin/com/getcode/libs/emojis/reactions/RecentReactionsVectorTest.kt index dcfcb44026..544167f4f2 100644 --- a/libs/emojis/src/test/kotlin/com/getcode/libs/emojis/reactions/RecentReactionsVectorTest.kt +++ b/libs/emojis/src/test/kotlin/com/getcode/libs/emojis/reactions/RecentReactionsVectorTest.kt @@ -15,8 +15,9 @@ import kotlin.time.Instant * is synced verbatim. A failure here is either a real regression or a cross-platform decision that * has to be made in the canonical fixture and re-synced to both platforms — never a local edit. * - * Covers `defaults`, `recents` and `drawability`; `merge`, `order` and `strip` are covered by - * `apps:flipcash:shared:chat`'s `ReactionVectorsTest`, next to where `ReactionState` lives. + * Covers `defaults` and `recents`; `merge`, `order` and `strip` are covered by + * `apps:flipcash:shared:chat`'s `ReactionVectorsTest`, next to where `ReactionState` lives, and + * `drawability` by the instrumented `EmojiDrawabilityVectorTest`, which needs a real emoji font. */ @RunWith(RobolectricTestRunner::class) @Config(manifest = Config.NONE) @@ -64,29 +65,6 @@ class RecentReactionsVectorTest { } } - /** - * [EmojiDrawability] delegates to [android.graphics.Paint.hasGlyph], which asks the device's - * real font fallback and so cannot be pinned to a fixed answer in a JVM unit test the way the - * pure-logic sections above can. This only checks that the predicate runs and returns a - * boolean for each vector's emoji, naming the vector so a crash points at the offending - * sequence; the actual drawability answer is verified on-device (see the module's - * instrumented/UI-level coverage; chunk B wires the strip and picker up to a real predicate). - */ - @Test - fun `drawability vectors run without crashing`() { - val vectors = section("drawability") - assertTrue(vectors.length() > 0, "reactions.json loaded no drawability vectors") - - for (i in 0 until vectors.length()) { - val vector = vectors.getJSONObject(i) - val emoji = vector.getString("emoji") - val note = vector.optString("note", "") - // Must not throw for any vector's emoji; the boolean result is real-device behavior. - EmojiDrawability.isDrawable(emoji) - assertTrue(true, "vector `$emoji`: $note") - } - } - private fun section(name: String): JSONArray { val json = javaClass.classLoader!! .getResourceAsStream("reactions.json")!! From 5f24d1f4948265530731983c48c8289dc7581278 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 18:14:39 -0400 Subject: [PATCH 38/58] fix(emojis): run the Hilt processor so the app sees ReactionsModule --- libs/emojis/build.gradle.kts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/libs/emojis/build.gradle.kts b/libs/emojis/build.gradle.kts index 6805d01557..a03124a6c2 100644 --- a/libs/emojis/build.gradle.kts +++ b/libs/emojis/build.gradle.kts @@ -1,5 +1,7 @@ plugins { alias(libs.plugins.flipcash.android.library) + id("com.google.devtools.ksp") + id("dagger.hilt.android.plugin") } android { @@ -8,6 +10,7 @@ android { dependencies { implementation(libs.bundles.hilt) + ksp(libs.bundles.hilt.compiler) implementation(libs.bundles.kotlinx.serialization) implementation(libs.kotlinx.datetime) implementation(libs.androidx.datastore) From a7320fbcccc977a4e05409c959f5764f54046207 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 18:35:02 -0400 Subject: [PATCH 39/58] fix(emojis): keep reaction recents per account --- .../chat/inject/RecentReactionsOwnerModule.kt | 18 ++++++++ .../libs/emojis/reactions/ReactionsModule.kt | 6 ++- .../emojis/reactions/RecentReactionsStore.kt | 20 +++++++-- .../reactions/RecentReactionsStoreTest.kt | 45 +++++++++++++++++++ 4 files changed, 84 insertions(+), 5 deletions(-) create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/RecentReactionsOwnerModule.kt create mode 100644 libs/emojis/src/test/kotlin/com/getcode/libs/emojis/reactions/RecentReactionsStoreTest.kt diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/RecentReactionsOwnerModule.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/RecentReactionsOwnerModule.kt new file mode 100644 index 0000000000..928f7dcb90 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/RecentReactionsOwnerModule.kt @@ -0,0 +1,18 @@ +package com.flipcash.shared.chat.inject + +import com.flipcash.services.user.UserManager +import com.getcode.libs.emojis.reactions.RecentReactionsOwner +import com.getcode.utils.hexEncodedString +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +object RecentReactionsOwnerModule { + + @Provides + fun providesRecentReactionsOwner(userManager: UserManager): RecentReactionsOwner = + RecentReactionsOwner { userManager.accountId?.hexEncodedString() } +} diff --git a/libs/emojis/src/main/kotlin/com/getcode/libs/emojis/reactions/ReactionsModule.kt b/libs/emojis/src/main/kotlin/com/getcode/libs/emojis/reactions/ReactionsModule.kt index 8e994f19f0..0040ce4a1b 100644 --- a/libs/emojis/src/main/kotlin/com/getcode/libs/emojis/reactions/ReactionsModule.kt +++ b/libs/emojis/src/main/kotlin/com/getcode/libs/emojis/reactions/ReactionsModule.kt @@ -14,8 +14,10 @@ object ReactionsModule { @Provides @Singleton - fun providesRecentReactionsStore(@ApplicationContext context: Context): RecentReactionsStore = - DataStoreRecentReactionsStore(context) + fun providesRecentReactionsStore( + @ApplicationContext context: Context, + owner: RecentReactionsOwner, + ): RecentReactionsStore = DataStoreRecentReactionsStore(context, owner) @Provides @Singleton diff --git a/libs/emojis/src/main/kotlin/com/getcode/libs/emojis/reactions/RecentReactionsStore.kt b/libs/emojis/src/main/kotlin/com/getcode/libs/emojis/reactions/RecentReactionsStore.kt index f43ca1d082..c78fe9f87c 100644 --- a/libs/emojis/src/main/kotlin/com/getcode/libs/emojis/reactions/RecentReactionsStore.kt +++ b/libs/emojis/src/main/kotlin/com/getcode/libs/emojis/reactions/RecentReactionsStore.kt @@ -28,17 +28,28 @@ interface RecentReactionsStore { suspend fun stats(): Map } +/** + * The signed-in account whose recents [DataStoreRecentReactionsStore] reads and writes, or null + * when nobody is signed in. One device serves every account, and switching back to one should find + * its recents where it left them, so each account's usage lives under its own keys. + */ +fun interface RecentReactionsOwner { + fun current(): String? +} + @Singleton class DataStoreRecentReactionsStore @Inject constructor( @ApplicationContext context: Context, + private val owner: RecentReactionsOwner, ) : RecentReactionsStore { private val dataStore = context.recentReactionsDataStore override suspend fun record(emoji: String) { + val owner = owner.current() ?: return val now = Clock.System.now() dataStore.edit { prefs -> - val key = key(emoji) + val key = key(owner, emoji) val current = decode(prefs[key]) prefs[key] = encode(RecentReactions.Usage(count = current.count + 1, lastUsed = now)) } @@ -48,16 +59,19 @@ class DataStoreRecentReactionsStore @Inject constructor( RecentReactions.rank(stats(), undrawable, limit) override suspend fun stats(): Map { + val prefix = prefix(owner.current() ?: return emptyMap()) val prefs = dataStore.data.first() return prefs.asMap().mapNotNull { (prefsKey, raw) -> - val emoji = prefsKey.name.removePrefix(KEY_PREFIX) + val emoji = prefsKey.name.removePrefix(prefix) if (emoji == prefsKey.name) return@mapNotNull null // not one of ours val usage = decode(raw as? String) ?: return@mapNotNull null emoji to usage }.toMap() } - private fun key(emoji: String) = stringPreferencesKey("$KEY_PREFIX$emoji") + private fun prefix(owner: String) = "$owner:$KEY_PREFIX" + + private fun key(owner: String, emoji: String) = stringPreferencesKey("${prefix(owner)}$emoji") private fun encode(usage: RecentReactions.Usage): String = "${usage.count}:${usage.lastUsed.epochSeconds}" diff --git a/libs/emojis/src/test/kotlin/com/getcode/libs/emojis/reactions/RecentReactionsStoreTest.kt b/libs/emojis/src/test/kotlin/com/getcode/libs/emojis/reactions/RecentReactionsStoreTest.kt new file mode 100644 index 0000000000..dbeb64d90d --- /dev/null +++ b/libs/emojis/src/test/kotlin/com/getcode/libs/emojis/reactions/RecentReactionsStoreTest.kt @@ -0,0 +1,45 @@ +package com.getcode.libs.emojis.reactions + +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +class RecentReactionsStoreTest { + + // The DataStore file outlives each test in this process, so every test uses owners of its own. + private var owner: String? = null + private val store = DataStoreRecentReactionsStore( + context = ApplicationProvider.getApplicationContext(), + owner = { owner }, + ) + + @Test + fun `each account keeps its own recents`() = runTest { + owner = "alice" + store.record("🎉") + store.record("🎉") + + owner = "bob" + assertTrue(store.stats().isEmpty(), "bob sees alice's recents") + store.record("🍕") + + owner = "alice" + assertEquals(setOf("🎉"), store.stats().keys) + assertEquals(2, store.stats().getValue("🎉").count) + } + + @Test + fun `nothing is recorded or read without a signed-in account`() = runTest { + owner = null + store.record("🎉") + assertTrue(store.stats().isEmpty()) + + owner = "carol" + assertTrue(store.stats().isEmpty()) + } +} From dfd201a2488ee6b34ae857470a6747d7f3f21648 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 18:38:36 -0400 Subject: [PATCH 40/58] fix(persistence): read reaction rows stored under the old sequence key --- .../app/persistence/converters/ChatTypeConverters.kt | 6 +++++- .../persistence/converters/ChatTypeConvertersTest.kt | 10 ++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt index ce9cd837bd..bc631733ab 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt @@ -4,9 +4,11 @@ import androidx.room.TypeConverter import com.flipcash.app.persistence.entities.MessageStatus import com.flipcash.services.models.VerifiableContactMethod import com.flipcash.services.models.chat.MediaItem +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNames private val json = Json { ignoreUnknownKeys = true @@ -289,13 +291,15 @@ data class ReactionSummarySerialized( val reactions: List, ) +@OptIn(ExperimentalSerializationApi::class) @Serializable data class EmojiReactionSerialized( val emoji: String, val count: Long, val selfReactor: ReactorSerialized? = null, val sampleReactors: List, - val version: Long, + // Rows written before the field was renamed to match the proto store it as `sequence`. + @JsonNames("sequence") val version: Long, ) @Serializable diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt index d8c4be9902..50fc96b43f 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt @@ -40,6 +40,16 @@ class ChatTypeConvertersTest { assertEquals(original, deserialized) } + @Test + fun `fromReactionSummary reads rows stored before sequence was renamed to version`() { + val stored = """{"messageId":42,"reactions":[{"emoji":"\uD83D\uDC4D","count":1,""" + + """"selfReactor":null,"sampleReactors":[],"sequence":7}]}""" + + val deserialized = converter.fromReactionSummary(stored) + + assertEquals(7L, deserialized?.reactions?.single()?.version) + } + @Test fun `fromReactionSummary returns null for null input`() { assertNull(converter.fromReactionSummary(null)) From d56a72e9dcd2e34308394cecdc90bca7d53962ea Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 18:39:05 -0400 Subject: [PATCH 41/58] fix(chat): fade overflowing reactor emoji and open profiles from the person only --- .../internal/screens/ReactorsSheet.kt | 176 +++++++++++++----- 1 file changed, 125 insertions(+), 51 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactorsSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactorsSheet.kt index d305c9dcca..878c247cb3 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactorsSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactorsSheet.kt @@ -5,13 +5,13 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -34,11 +34,21 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -114,13 +124,10 @@ internal fun ReactorsSheet( state = listState, modifier = Modifier .fillMaxWidth() - .heightIn(max = 480.dp) .navigationBarsPadding(), - contentPadding = PaddingValues( - horizontal = CodeTheme.dimens.inset, - vertical = CodeTheme.dimens.grid.x3, - ), - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + // No horizontal padding: rows inset their own leading edge, and overflowing emoji run + // off the screen edge rather than stopping at a margin. + contentPadding = PaddingValues(vertical = CodeTheme.dimens.grid.x2), ) { if (showPlaceholders) { // min(max pill count, 5) placeholder rows. @@ -191,7 +198,10 @@ private fun SummaryPillRow(pills: List) { } } -/** One reactor: avatar, name/handle, and every emoji they reacted with. */ +/** + * One reactor: avatar and name/handle, then every emoji they reacted with. Only the person opens + * their profile; the emoji are theirs to scroll, not a tap target. + */ @Composable private fun ReactorRow( userId: ID, @@ -202,54 +212,83 @@ private fun ReactorRow( val displayFlow = remember(userId, resolveDisplay) { resolveDisplay(userId) } val display by displayFlow.collectAsStateWithLifecycle(initialValue = null) - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(CodeTheme.dimens.grid.x2)) - .clickable(enabled = display != null, onClick = onClick), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), - ) { - val profile = display?.profile - if (profile != null) { - ContactAvatar(userProfile = profile, modifier = Modifier.size(48.dp).clip(CircleShape)) - } else { - Box( + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + // The person keeps priority over the emoji, which always get at least MinEmojiWidth. + val personMaxWidth = (maxWidth - RowLeadingInset - MinEmojiWidth).coerceAtLeast(0.dp) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = RowLeadingInset) + .padding(vertical = 8.5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Row( modifier = Modifier - .size(48.dp) - .clip(CircleShape) - .background(Brush.linearGradient(CodeTheme.colors.contactAvatar.colors)), - ) - } + .widthIn(max = personMaxWidth) + .clickable(enabled = display != null, onClick = onClick), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(15.dp), + ) { + val profile = display?.profile + if (profile != null) { + ContactAvatar(userProfile = profile, modifier = Modifier.size(48.dp).clip(CircleShape)) + } else { + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(Brush.linearGradient(CodeTheme.colors.contactAvatar.colors)), + ) + } - Column(modifier = Modifier.weight(1f)) { - Text( - text = display?.name ?: "", - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - color = CodeTheme.colors.textMain, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - val handle = profile?.handle - if (!handle.isNullOrBlank()) { - Text( - text = handle, - fontSize = 13.sp, - color = CodeTheme.colors.textSecondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Column { + Text( + text = display?.name ?: "", + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val handle = profile?.handle + if (!handle.isNullOrBlank()) { + Text( + text = handle, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = CodeTheme.colors.textMain.copy(alpha = 0.5f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } } + + ReactorEmojis(emojis = emojis, modifier = Modifier.weight(1f)) } + } +} - // Horizontal scroll with edge fades only when it overflows its min width — every row draws - // at least 140.dp so a one-emoji row doesn't shrink to a sliver next to a five-emoji one. - val scrollState = rememberScrollState() +/** + * A reactor's emoji, trailing-aligned in whatever the name leaves. When there are more than fit + * they scroll sideways, opening on the first emoji, and fade out across the margins instead of + * stopping at a hard edge. The leading margin is also the gap to the name. + */ +@Composable +private fun ReactorEmojis(emojis: List, modifier: Modifier = Modifier) { + val scrollState = rememberScrollState() + val overflows = scrollState.maxValue > 0 + Box( + modifier = modifier + .semantics(mergeDescendants = true) { contentDescription = emojis.joinToString(" ") } + .then(if (overflows) Modifier.edgeFade(EmojiLeadingMargin, EmojiTrailingMargin) else Modifier), + contentAlignment = Alignment.CenterEnd, + ) { Row( modifier = Modifier - .widthIn(min = 140.dp) - .horizontalScroll(scrollState), + .horizontalScroll(scrollState) + .padding(start = EmojiLeadingMargin, end = EmojiTrailingMargin) + .clearAndSetSemantics { }, horizontalArrangement = Arrangement.spacedBy(2.dp), ) { for (emoji in emojis) { @@ -259,12 +298,47 @@ private fun ReactorRow( } } +/** Fades content out across [leading] and [trailing], eased so the fade has no visible start line. */ +private fun Modifier.edgeFade(leading: Dp, trailing: Dp): Modifier = this + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .drawWithContent { + drawContent() + val leadingPx = leading.toPx() + val trailingPx = trailing.toPx() + drawRect( + brush = Brush.horizontalGradient(*EdgeFadeStops, startX = 0f, endX = leadingPx), + size = Size(leadingPx, size.height), + blendMode = BlendMode.DstIn, + ) + drawRect( + brush = Brush.horizontalGradient(*EdgeFadeStops, startX = size.width, endX = size.width - trailingPx), + topLeft = Offset(size.width - trailingPx, 0f), + size = Size(trailingPx, size.height), + blendMode = BlendMode.DstIn, + ) + } + +private val EdgeFadeStops = arrayOf( + 0f to Color.Black.copy(alpha = 0f), + 0.25f to Color.Black.copy(alpha = 0.15f), + 0.5f to Color.Black.copy(alpha = 0.5f), + 0.75f to Color.Black.copy(alpha = 0.85f), + 1f to Color.Black, +) + +private val RowLeadingInset = 20.dp +private val MinEmojiWidth = 140.dp +private val EmojiLeadingMargin = 16.dp +private val EmojiTrailingMargin = 20.dp + @Composable private fun ReactorRowPlaceholder() { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = RowLeadingInset, vertical = 8.5.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + horizontalArrangement = Arrangement.spacedBy(15.dp), ) { Box( modifier = Modifier From 3252c9d3deb087036333c97ea1c5cae9ee314647 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 18:46:30 -0400 Subject: [PATCH 42/58] feat(messenger): match reaction picker sheet to iOS Rework ReactionPickerSheet to mirror iOS's EmojiPickerSheet (node 9768:1402): drop the reused reactors-sheet title, replace the search row with a black-.26 capsule (magnifier + Search + clear), let the 7-column grid fill the sheet as it expands, and add a floating Haze glass category bar over the bottom of the grid with a scroll-synced selection indicator. Show a spinner while the catalog loads and the existing no-results copy when a search comes back empty. Departure from iOS: no drag-across-the-bar gesture on the category bar, tap-only. --- .../core/src/main/res/values/strings.xml | 3 +- .../internal/screens/ReactionPickerSheet.kt | 384 +++++++++++++----- 2 files changed, 295 insertions(+), 92 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 5ac6522054..deb72fcb6d 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1008,7 +1008,8 @@ This message has the maximum number of reactions %1$d Reactions Reactions - Search Emoji + Search + Clear search Unknown Contact Allow Full Contact Access diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactionPickerSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactionPickerSheet.kt index 94b6d01d50..f169e47dff 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactionPickerSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactionPickerSheet.kt @@ -1,26 +1,33 @@ package com.flipcash.app.messenger.internal.screens +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.clearText import androidx.compose.material.Icon import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Cancel +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -29,31 +36,46 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.features.messenger.R +import com.getcode.libs.emojis.reactions.EmojiCatalogEntry import com.getcode.libs.emojis.reactions.EmojiPickerModel import com.getcode.theme.CodeTheme -import com.getcode.theme.inputColors -import com.getcode.ui.components.AppBarDefaults -import com.getcode.ui.components.AppBarWithTitle -import com.getcode.ui.components.TextInput import com.getcode.ui.core.unboundedClickable +import com.getcode.ui.theme.CodeCircularProgressIndicator import com.getcode.ui.utils.AllowSheetExpansionWhenScrollable +import dev.chrisbanes.haze.HazeInput +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.blur.HazeBlurStyle +import dev.chrisbanes.haze.blur.HazeColorEffect +import dev.chrisbanes.haze.blur.hazeBlur +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch /** * The full emoji picker (decision 5: opens as `ChatStep.ReactionPicker`, a [com.getcode.ui.navigation.HalfSheet]). - * A search field (not auto-focused — iOS's picker doesn't auto-focus its search field either), - * a Frequently Used row, then every catalog category, all through one scrollable grid so - * [AllowSheetExpansionWhenScrollable] sees a single scroll container. + * A search capsule (not auto-focused — iOS's picker doesn't auto-focus its search field either), + * then every catalog category in a fixed 7-column grid behind a floating glass category bar, all + * through one scrollable list so [AllowSheetExpansionWhenScrollable] sees a single scroll container. + * Mirrors iOS `EmojiPickerSheet` (node 9768:1402, category bar node 9768:1624). There is no title — + * iOS's picker doesn't have one either. */ @Composable internal fun ReactionPickerSheet( @@ -67,18 +89,12 @@ internal fun ReactionPickerSheet( val searchState = remember { TextFieldState() } Column(modifier = Modifier.fillMaxWidth()) { - AppBarWithTitle( - title = stringResource(R.string.title_reactorsSheet), - titleAlignment = Alignment.CenterHorizontally, - endContent = { AppBarDefaults.Close(onClick = onDismiss) }, - ) - SearchField( state = searchState, modifier = Modifier .fillMaxWidth() - .padding(horizontal = CodeTheme.dimens.inset) - .padding(bottom = CodeTheme.dimens.grid.x2), + .padding(horizontal = 16.dp) + .padding(top = 16.dp, bottom = 12.dp), ) val query by produceState(initialValue = "") { @@ -88,109 +104,295 @@ internal fun ReactionPickerSheet( .collect { value = it } } + var hasLoadedOnce by remember { mutableStateOf(false) } val sections by produceState(initialValue = emptyList(), query, loadSections) { value = loadSections(query) + hasLoadedOnce = true } - EmojiGrid( - sections = sections, - gridState = gridState, - onSelected = onSelected, - ) + when { + !hasLoadedOnce -> { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CodeCircularProgressIndicator() + } + } + + sections.isEmpty() && query.isNotBlank() -> { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = stringResource(R.string.title_noSearchResults, query), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 32.dp), + ) + } + } + + else -> { + EmojiGrid( + sections = sections, + query = query, + gridState = gridState, + onSelected = onSelected, + ) + } + } } } private const val COLUMNS = 7 +/** One flat entry in the grid's backing list, keyed so a category-bar tap can find a section's row. */ +private sealed interface GridRow { + val sectionId: String + + data class Header(override val sectionId: String, val title: String) : GridRow + data class Emojis(override val sectionId: String, val entries: List) : GridRow +} + @Composable private fun EmojiGrid( sections: List, + query: String, gridState: LazyListState, onSelected: (String) -> Unit, ) { // A LazyColumn of hand-chunked rows rather than LazyVerticalGrid: AllowSheetExpansionWhenScrollable // only reads a LazyListState, and one flat list keeps every section's header + rows in the same - // scroll container it watches. - LazyColumn( - state = gridState, - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 480.dp) - .navigationBarsPadding(), - contentPadding = PaddingValues(horizontal = CodeTheme.dimens.inset, vertical = CodeTheme.dimens.grid.x2), - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), - ) { - for (section in sections) { - item(key = "header-${section.id}") { - Text( - text = section.title, - fontSize = 13.sp, - color = CodeTheme.colors.textSecondary, - modifier = Modifier.padding( - top = CodeTheme.dimens.grid.x2, - bottom = CodeTheme.dimens.grid.x1, - ), - ) + // scroll container it watches. It also fills the sheet as it expands to 92.5% (no heightIn cap). + val rows = remember(sections) { + buildList { + for (section in sections) { + if (section.id != EmojiPickerModel.SEARCH_RESULTS_ID) { + add(GridRow.Header(section.id, section.title)) + } + for (chunk in section.entries.chunked(COLUMNS)) { + add(GridRow.Emojis(section.id, chunk)) + } } - val rows = section.entries.chunked(COLUMNS) - items(rows, key = { row -> "${section.id}-${row.first().emoji}" }) { row -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), - ) { - for (entry in row) { + } + } + + // The flat-list index a category-bar tap scrolls to — the header when the section has one + // (every catalog category does; search results never show the bar at all). + val sectionStartIndex = remember(rows) { + val map = LinkedHashMap() + rows.forEachIndexed { index, row -> map.putIfAbsent(row.sectionId, index) } + map + } + + val showBar = query.isBlank() && sections.size > 1 + val hazeState = rememberHazeState() + val scope = rememberCoroutineScope() + + // A category chosen from the bar whose section the grid is still scrolling to — the indicator + // holds on it so it doesn't pass through every category the grid scrolls over on the way + // (mirrors iOS's `pendingCategory`). + var pendingCategory by remember { mutableStateOf(null) } + val visibleCategory by remember { + derivedStateOf { + val visibleIndex = gridState.firstVisibleItemIndex + sectionStartIndex.entries + .filter { it.value <= visibleIndex } + .maxByOrNull { it.value } + ?.key + } + } + LaunchedEffect(visibleCategory) { + if (visibleCategory == pendingCategory) pendingCategory = null + } + val selectedCategory = pendingCategory ?: visibleCategory ?: sections.firstOrNull()?.id + + Box(modifier = Modifier.fillMaxSize().navigationBarsPadding()) { + LazyColumn( + state = gridState, + modifier = Modifier + .fillMaxSize() + .hazeSource(hazeState), + contentPadding = PaddingValues( + top = 4.dp, + bottom = if (showBar) 45.dp + 24.dp else 16.dp, + ), + ) { + items(rows, key = { row -> + when (row) { + is GridRow.Header -> "header-${row.sectionId}" + is GridRow.Emojis -> "row-${row.sectionId}-${row.entries.first().emoji}" + } + }) { row -> + when (row) { + is GridRow.Header -> { Text( - text = entry.emoji, - fontSize = 28.sp, + text = row.title.uppercase(), + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + color = CodeTheme.colors.textMain.copy(alpha = 0.5f), modifier = Modifier - .size(44.dp) - .wrapContentWidth(Alignment.CenterHorizontally) - .unboundedClickable { onSelected(entry.emoji) }, + .padding(horizontal = 16.dp) + .padding(top = 20.dp, bottom = 12.dp), ) } - if (row.size < COLUMNS) { - Box(modifier = Modifier.width((44 * (COLUMNS - row.size)).dp)) + + is GridRow.Emojis -> { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 12.dp), + ) { + for (entry in row.entries) { + EmojiCell(entry = entry, onSelected = onSelected) + } + repeat(COLUMNS - row.entries.size) { + Box(modifier = Modifier.weight(1f)) + } + } } } } } + + if (showBar) { + CategoryBar( + sections = sections, + selected = selectedCategory, + hazeState = hazeState, + onSelect = { id -> + pendingCategory = id + val index = sectionStartIndex[id] ?: return@CategoryBar + scope.launch { gridState.animateScrollToItem(index) } + }, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 4.dp), + ) + } } } @Composable -private fun SearchField(state: TextFieldState, modifier: Modifier = Modifier) { - TextInput( - modifier = modifier, - shape = CodeTheme.shapes.medium, - state = state, - placeholder = stringResource(R.string.hint_searchEmoji), - minHeight = CodeTheme.dimens.grid.x8, - contentPadding = PaddingValues( - start = 8.dp + CodeTheme.dimens.staticGrid.x2, - top = 8.dp, - end = 8.dp + CodeTheme.dimens.staticGrid.x2, - bottom = 8.dp, - ), - maxLines = 1, - colors = inputColors( - backgroundColor = CodeTheme.colors.surface, - textColor = CodeTheme.colors.textMain, - cursorColor = CodeTheme.colors.textMain, - placeholderColor = CodeTheme.colors.textSecondary, - ), - trailingIcon = state.text.takeIf { it.isNotBlank() }?.let { - { - Icon( - imageVector = Icons.Filled.Cancel, - contentDescription = "", - tint = CodeTheme.colors.textSecondary, - modifier = Modifier - .padding(end = CodeTheme.dimens.grid.x2) - .wrapContentWidth() - .size(20.dp) - .unboundedClickable { state.clearText() }, +private fun RowScope.EmojiCell(entry: EmojiCatalogEntry, onSelected: (String) -> Unit) { + Box( + modifier = Modifier + .weight(1f) + .heightIn(min = 41.dp) + .unboundedClickable { onSelected(entry.emoji) } + .semantics { contentDescription = entry.name }, + contentAlignment = Alignment.Center, + ) { + Text(text = entry.emoji, fontSize = 34.sp) + } +} + +/** + * The floating jump bar to each category, a glass capsule over the grid (node 9768:1624), frosted + * with a Haze blur of the grid scrolling beneath it — Android's analogue of iOS's `.glassEffect` + * (see [com.flipcash.app.core.ui.NavigationBar] for the same pattern). Unlike iOS, there is no + * drag-across-the-bar gesture — only a tap per slot (a deliberate scope cut; see the chunk report). + */ +@Composable +private fun CategoryBar( + sections: List, + selected: String?, + hazeState: HazeState, + onSelect: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val backdrop = CodeTheme.colors.background + val liquidGlass = HazeBlurStyle { + blurRadius(32.dp) + backgroundColor(backdrop) + colorEffects(listOf(HazeColorEffect.tint(backdrop.copy(alpha = 0.72f)))) + } + + Box( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 30.dp) + .height(45.dp) + .clip(CircleShape) + .hazeBlur(HazeInput.Sources(hazeState), liquidGlass), + ) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 6.dp), + ) { + for (section in sections) { + val isSelected = section.id == selected + val indicatorColor by animateColorAsState( + targetValue = if (isSelected) { + CodeTheme.colors.textMain.copy(alpha = 0.14f) + } else { + Color.Transparent + }, + label = "categoryBarIndicator", ) + Box( + modifier = Modifier + .weight(1f) + .fillMaxSize() + .clip(CircleShape) + .background(indicatorColor) + .unboundedClickable { onSelect(section.id) } + .semantics { contentDescription = section.title }, + contentAlignment = Alignment.Center, + ) { + Text(text = section.entries.firstOrNull()?.emoji.orEmpty(), fontSize = 20.sp) + } } - }, - ) + } + } +} + +@Composable +private fun SearchField(state: TextFieldState, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .height(44.dp) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.26f)) + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Filled.Search, + contentDescription = null, + tint = CodeTheme.colors.textSecondary, + modifier = Modifier.size(20.dp), + ) + Box( + modifier = Modifier + .weight(1f) + .padding(start = 8.dp), + ) { + BasicTextField( + state = state, + textStyle = CodeTheme.typography.textMedium.copy(color = CodeTheme.colors.textMain), + cursorBrush = SolidColor(CodeTheme.colors.textMain), + lineLimits = TextFieldLineLimits.SingleLine, + decorator = { inner -> + if (state.text.isEmpty()) { + Text( + text = stringResource(R.string.hint_searchEmoji), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textSecondary, + ) + } + inner() + }, + ) + } + if (state.text.isNotEmpty()) { + Icon( + imageVector = Icons.Filled.Cancel, + contentDescription = stringResource(R.string.action_clearEmojiSearch), + tint = CodeTheme.colors.textSecondary, + modifier = Modifier + .size(20.dp) + .unboundedClickable { state.clearText() }, + ) + } + } } From f8ef0e228f6b6e6b1a627997029e74a4c9727b4d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 18:52:12 -0400 Subject: [PATCH 43/58] test(chat): cover ReactionPillRow and QuickReactionStrip Tap/long-press/plus wiring, the previewer's read-only contract, narrow-width N-more collapse, selection semantics, and the quick strip's scroll-to-overflow all lacked coverage. ReactionPillRow's pill tap called onToggle even when canReact was false, so a previewer's tap silently registered a reaction attempt instead of doing nothing. Gate it on canReact. Also tag pills/more/plus chips for lookup and expose selfReacted/highlighted as semantics selected, since neither existed to assert against. --- .../shared/chat/ui/ReactionPillRow.kt | 21 ++- .../shared/chat/ui/QuickReactionStripTest.kt | 105 +++++++++++ .../shared/chat/ui/ReactionPillRowTest.kt | 173 ++++++++++++++++++ 3 files changed, 296 insertions(+), 3 deletions(-) create mode 100644 apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/QuickReactionStripTest.kt create mode 100644 apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ReactionPillRowTest.kt diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionPillRow.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionPillRow.kt index 6c10b3118f..585bdc564b 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionPillRow.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionPillRow.kt @@ -22,6 +22,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp @@ -177,7 +180,9 @@ fun ReactionPillRow( pill = pill, height = pillHeight, horizontalPadding = pillPadding, - onClick = { onToggle(pill.emoji) }, + // A previewer (canReact == false) can still open the reactors sheet from a + // long-press, but a tap must not toggle a reaction they're not allowed to make. + onClick = if (canReact) ({ onToggle(pill.emoji) }) else null, onLongClick = onPillLongClick, ) } @@ -293,6 +298,8 @@ fun QuickReactionStrip( entries.forEach { entry -> Box( modifier = Modifier + .testTag("quick_reaction_${entry.emoji}") + .semantics { selected = entry.highlighted } .size(32.dp) .let { base -> if (entry.highlighted) { @@ -309,6 +316,7 @@ fun QuickReactionStrip( } Box( modifier = Modifier + .testTag("quick_reaction_plus") .size(32.dp) .background(Color.White.copy(alpha = 0.06f), CircleShape) .combinedClickable(onClick = onOpenPicker, onLongClick = {}), @@ -324,7 +332,7 @@ private fun ReactionPillChip( pill: ReactionPill, height: Dp, horizontalPadding: Dp, - onClick: () -> Unit, + onClick: (() -> Unit)?, onLongClick: () -> Unit, ) { val fill = if (pill.selfReacted) { @@ -334,6 +342,8 @@ private fun ReactionPillChip( } Box( modifier = Modifier + .testTag("reaction_pill_${pill.emoji}") + .semantics { selected = pill.selfReacted } .background(color = fill, shape = RoundedCornerShape(percent = 50)) .let { base -> if (pill.selfReacted) { @@ -346,7 +356,7 @@ private fun ReactionPillChip( base } } - .combinedClickable(onClick = onClick, onLongClick = onLongClick) + .combinedClickable(onClick = onClick ?: {}, onLongClick = onLongClick) .padding(horizontal = horizontalPadding), contentAlignment = Alignment.Center, ) { @@ -363,6 +373,10 @@ private fun MorePillChip( ) { Box( modifier = Modifier + // Only the real "more" chip is tagged: the width-estimate slot SubcomposeLayout keeps + // composed (to size the collapse ahead of the real decision) would otherwise carry the + // same tag into the semantics tree even though it's never placed. + .let { base -> if (onClick != null) base.testTag("reaction_pill_more") else base } .background( color = Color.White.copy(alpha = 0.06f), shape = RoundedCornerShape(percent = 50), @@ -379,6 +393,7 @@ private fun MorePillChip( private fun PlusChip(size: Dp, onClick: () -> Unit) { Box( modifier = Modifier + .testTag("reaction_pill_plus") .background( color = Color.White.copy(alpha = 0.06f), shape = RoundedCornerShape(percent = 50), diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/QuickReactionStripTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/QuickReactionStripTest.kt new file mode 100644 index 0000000000..dffbc1f1e3 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/QuickReactionStripTest.kt @@ -0,0 +1,105 @@ +package com.flipcash.shared.chat.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.width +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.unit.dp +import com.flipcash.shared.chat.reactions.ReactionStrip +import com.getcode.theme.DesignSystem +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals + +/** + * [QuickReactionStrip]: the horizontally-scrolling strip a long-press on a reactable, selected + * bubble shows (decision 5). Every entry a caller hands it renders, self-reacted ones read as + * selected, and a tap is the one signal that both toggles and (from the caller's side) clears the + * selection. + */ +@RunWith(RobolectricTestRunner::class) +class QuickReactionStripTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private fun entry(emoji: String, highlighted: Boolean = false) = + ReactionStrip.Entry(emoji = emoji, highlighted = highlighted) + + private fun setStrip( + entries: List, + width: androidx.compose.ui.unit.Dp = 200.dp, + onToggle: (String) -> Unit = {}, + onOpenPicker: () -> Unit = {}, + ) { + composeTestRule.setContent { + DesignSystem { + Box(Modifier.width(width)) { + QuickReactionStrip( + entries = entries, + onToggle = onToggle, + onOpenPicker = onOpenPicker, + ) + } + } + } + } + + @Test + fun `every entry renders`() { + val entries = listOf(entry("❤️"), entry("👍"), entry("😂")) + setStrip(entries = entries, width = 2000.dp) + + entries.forEach { composeTestRule.onNodeWithTag("quick_reaction_${it.emoji}").assertExists() } + } + + @Test + fun `self-reacted entries are marked selected`() { + setStrip(entries = listOf(entry("❤️", highlighted = true), entry("👍", highlighted = false))) + + composeTestRule.onNodeWithTag("quick_reaction_❤️").assertIsSelected() + composeTestRule.onNodeWithTag("quick_reaction_👍").assertIsNotSelected() + } + + @Test + fun `tapping an entry toggles that emoji`() { + val toggled = mutableListOf() + setStrip(entries = listOf(entry("❤️"), entry("👍")), onToggle = { toggled += it }) + + composeTestRule.onNodeWithTag("quick_reaction_👍").performClick() + + composeTestRule.runOnIdle { assertEquals(listOf("👍"), toggled) } + } + + @Test + fun `plus opens the picker`() { + var opened = 0 + setStrip(entries = listOf(entry("❤️")), onOpenPicker = { opened++ }) + + composeTestRule.onNodeWithTag("quick_reaction_plus").performClick() + + composeTestRule.runOnIdle { assertEquals(1, opened) } + } + + @Test + fun `overflowing entries are reachable by horizontal scroll`() { + // The strip doesn't dedupe entries, so 29 identical filler emoji plus one distinguishable + // target at the end is enough to force overflow at a narrow width and give the test + // something unique to scroll to. + val entries = List(29) { entry("😀") } + entry("🔥") + setStrip(entries = entries, width = 120.dp) + + composeTestRule.onRoot().performScrollToNode(hasTestTag("quick_reaction_🔥")) + + composeTestRule.onNodeWithTag("quick_reaction_🔥").assertExists() + } +} diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ReactionPillRowTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ReactionPillRowTest.kt new file mode 100644 index 0000000000..f8d172e110 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ReactionPillRowTest.kt @@ -0,0 +1,173 @@ +package com.flipcash.shared.chat.ui + +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.width +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.longClick +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.unit.dp +import com.flipcash.shared.chat.reactions.ReactionPill +import com.getcode.theme.DesignSystem +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * [ReactionPillRow]: the pill row a bubble draws its reaction summary in. Behavior, not pixels — + * callback wiring, the previewer's read-only contract (decision 2, #17), and the "N more" collapse + * an narrow bubble forces (the layout math itself is [ReactionPillRowLayoutTest]'s job). + */ +@RunWith(RobolectricTestRunner::class) +class ReactionPillRowTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private fun pill( + emoji: String, + count: Long = 1, + selfReacted: Boolean = false, + pending: Boolean = false, + ) = ReactionPill(emoji = emoji, count = count, selfReacted = selfReacted, pending = pending) + + private fun setRow( + pills: List, + canReact: Boolean = true, + width: androidx.compose.ui.unit.Dp = 400.dp, + onToggle: (String) -> Unit = {}, + onPillLongClick: () -> Unit = {}, + onOpenPicker: () -> Unit = {}, + ) { + composeTestRule.setContent { + DesignSystem { + Box(Modifier.width(width)) { + ReactionPillRow( + pills = pills, + canReact = canReact, + onToggle = onToggle, + onPillLongClick = onPillLongClick, + onOpenPicker = onOpenPicker, + ) + } + } + } + } + + @Test + fun `tapping a pill toggles that emoji`() { + val toggled = mutableListOf() + setRow(pills = listOf(pill("😀"), pill("😂")), onToggle = { toggled += it }) + + composeTestRule.onNodeWithTag("reaction_pill_😂").performClick() + + composeTestRule.runOnIdle { assertEquals(listOf("😂"), toggled) } + } + + @Test + fun `long-pressing a pill only opens reactors, and does not reach a parent long-press`() { + val toggled = mutableListOf() + var reactorsOpened = 0 + var parentLongPresses = 0 + + composeTestRule.setContent { + DesignSystem { + Box( + Modifier + .width(400.dp) + .combinedClickable(onClick = {}, onLongClick = { parentLongPresses++ }), + ) { + ReactionPillRow( + pills = listOf(pill("😀"), pill("😂")), + canReact = true, + onToggle = { toggled += it }, + onPillLongClick = { reactorsOpened++ }, + onOpenPicker = {}, + ) + } + } + } + + composeTestRule.onNodeWithTag("reaction_pill_😂").performTouchInput { longClick() } + + composeTestRule.runOnIdle { + assertEquals(1, reactorsOpened) + assertEquals(emptyList(), toggled) + assertEquals(0, parentLongPresses) + } + } + + @Test + fun `plus opens the picker`() { + var opened = 0 + setRow(pills = listOf(pill("😀")), onOpenPicker = { opened++ }) + + composeTestRule.onNodeWithTag("reaction_pill_plus").performClick() + + composeTestRule.runOnIdle { assertEquals(1, opened) } + } + + @Test + fun `a previewer sees pills and can open reactors, but has no plus and taps do nothing`() { + val toggled = mutableListOf() + var reactorsOpened = 0 + setRow( + pills = listOf(pill("😀")), + canReact = false, + onToggle = { toggled += it }, + onPillLongClick = { reactorsOpened++ }, + ) + + composeTestRule.onNodeWithTag("reaction_pill_plus").assertDoesNotExist() + + composeTestRule.onNodeWithTag("reaction_pill_😀").performClick() + composeTestRule.onNodeWithTag("reaction_pill_😀").performTouchInput { longClick() } + + composeTestRule.runOnIdle { + assertTrue(toggled.isEmpty()) + assertEquals(1, reactorsOpened) + } + } + + @Test + fun `a selected pill is exposed as selected in semantics`() { + setRow(pills = listOf(pill("😀", selfReacted = true), pill("😂", selfReacted = false))) + + composeTestRule.onNodeWithTag("reaction_pill_😀").assertIsSelected() + composeTestRule.onNodeWithTag("reaction_pill_😂").assertIsNotSelected() + } + + @Test + fun `wrapping at a narrow width collapses into an N more pill before plus, which expands on tap`() { + val pills = listOf( + pill("😀"), pill("😁"), pill("😂"), pill("😃"), + pill("😄"), pill("😅"), pill("😆"), pill("😇"), + ) + setRow(pills = pills, width = 90.dp) + + // Narrow enough that not everything fits on two lines: a collapse pill stands in for the + // pills pushed off, placed ahead of "+". The row's SubcomposeLayout still subcomposes every + // pill to measure it, so a collapsed one stays in the semantics tree unplaced — displayed, + // not existence, is the check that actually distinguishes "shown" from "collapsed away". + composeTestRule.onNodeWithTag("reaction_pill_more").assertIsDisplayed() + composeTestRule.onNodeWithTag("reaction_pill_😇").assertIsNotDisplayed() + + composeTestRule.onNodeWithTag("reaction_pill_more").performClick() + + composeTestRule.runOnIdle { + composeTestRule.onNodeWithTag("reaction_pill_more").assertDoesNotExist() + composeTestRule.onNodeWithTag("reaction_pill_😇").assertIsDisplayed() + } + } +} From 79952761dc76bfffbbe1398cede02e5790a04127 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 19:04:26 -0400 Subject: [PATCH 44/58] fix(chat): draw no pill row until a message has a reaction, and size "+" to 28dp iOS hides the row and its "+" while a message has no reactions; Android drew a "+" under every message. PlusChip also ignored its size, so the chip shrank to the glyph. --- .../internal/screens/components/MessageRow.kt | 2 +- .../flipcash/shared/chat/ui/ReactionPillRow.kt | 7 +++++-- .../shared/chat/ui/ReactionPillRowTest.kt | 18 ++++++++++++++++++ 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt index 9b52881980..a916f8c23b 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt @@ -344,7 +344,7 @@ internal fun MessageRow( // Same width as the bubble above it (decision 2) — matched here // against the same fraction MessageBubble sizes a text/reply/deleted // bubble to, since the row doesn't expose its resolved width outward. - if (!selecting && (item.reactionPills.isNotEmpty() || item.canReact)) { + if (!selecting && item.reactionPills.isNotEmpty()) { BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { ReactionPillRow( pills = item.reactionPills, diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionPillRow.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionPillRow.kt index 585bdc564b..0736a3160c 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionPillRow.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionPillRow.kt @@ -146,7 +146,8 @@ object ReactionPillRowLayout { * select the message — its own `combinedClickable` handles the long-press so it never reaches the * bubble's. "N more" expands the row in place — every pill then draws, unbounded lines — and * stays expanded for the rest of this composition. "+" (hidden when `canReact` is false) opens - * the reaction picker. + * the reaction picker. With no [pills] nothing draws: the long-press strip is how a first + * reaction gets added, as on iOS. */ @Composable fun ReactionPillRow( @@ -159,7 +160,8 @@ fun ReactionPillRow( alignEnd: Boolean = false, maxLines: Int = 2, ) { - if (pills.isEmpty() && !canReact) return + // iOS draws no row at all until a message has a reaction; "+" only rides along with pills. + if (pills.isEmpty()) return var expanded by remember(pills) { mutableStateOf(false) } @@ -394,6 +396,7 @@ private fun PlusChip(size: Dp, onClick: () -> Unit) { Box( modifier = Modifier .testTag("reaction_pill_plus") + .size(size) .background( color = Color.White.copy(alpha = 0.06f), shape = RoundedCornerShape(percent = 50), diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ReactionPillRowTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ReactionPillRowTest.kt index f8d172e110..7c91e93847 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ReactionPillRowTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ReactionPillRowTest.kt @@ -8,6 +8,8 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsNotDisplayed import androidx.compose.ui.test.assertIsNotSelected import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.assertHeightIsEqualTo +import androidx.compose.ui.test.assertWidthIsEqualTo import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.longClick import androidx.compose.ui.test.onNodeWithTag @@ -65,6 +67,22 @@ class ReactionPillRowTest { } } + @Test + fun `a message with no reactions draws no row and no plus, matching iOS`() { + setRow(pills = emptyList(), canReact = true) + + composeTestRule.onNodeWithTag("reaction_pill_plus").assertDoesNotExist() + } + + @Test + fun `plus is a 28dp circle`() { + setRow(pills = listOf(pill("😀"))) + + composeTestRule.onNodeWithTag("reaction_pill_plus") + .assertWidthIsEqualTo(28.dp) + .assertHeightIsEqualTo(28.dp) + } + @Test fun `tapping a pill toggles that emoji`() { val toggled = mutableListOf() From 3218bc3b5bd4d92f788608749ca7b7a4a54945f3 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 19:08:40 -0400 Subject: [PATCH 45/58] fix(chat): place the quick reaction strip beside the bubble, sized as on iOS The strip drew inside the bubble's box shifted up 12dp, so it covered the bubble and ran to the screen edge with "+" scrolled out of view. It now sits in a popup 16dp above the bubble (below it when the selection bar is in the way), hugging the sender's side and 16dp inside the window, at iOS's 55dp height and 313dp max width, with "+" pinned outside the scrolling emoji. --- .../internal/screens/components/MessageRow.kt | 16 +- .../shared/chat/ui/QuickReactionStrip.kt | 217 ++++++++++++++++++ .../shared/chat/ui/ReactionPillRow.kt | 64 ------ .../ui/QuickReactionStripPlacementTest.kt | 69 ++++++ 4 files changed, 296 insertions(+), 70 deletions(-) create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/QuickReactionStrip.kt create mode 100644 apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/QuickReactionStripPlacementTest.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt index a916f8c23b..5ee0a323b2 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt @@ -10,6 +10,9 @@ import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -57,7 +60,7 @@ import com.flipcash.shared.chat.models.SeparatorConfig import com.flipcash.features.messenger.R import com.flipcash.shared.chat.reactions.ReactionStrip import com.flipcash.shared.chat.ui.ContentBubble -import com.flipcash.shared.chat.ui.QuickReactionStrip +import com.flipcash.shared.chat.ui.QuickReactionStripPopup import com.flipcash.shared.chat.ui.ReactionPillRow import com.flipcash.shared.chat.ui.rendersBare import com.flipcash.shared.common.ui.ContactAvatar @@ -323,8 +326,9 @@ internal fun MessageRow( // reach for a reaction with something faster than opening the // picker, and disappears the moment selection moves off. if (selecting && focused && item.canReact && quickReactionStrip.isNotEmpty()) { - QuickReactionStrip( + QuickReactionStripPopup( entries = quickReactionStrip, + hugsTrailing = item.isFromSelf, onToggle = { emoji -> onAction( ChatAction.ToggleReaction( @@ -335,9 +339,9 @@ internal fun MessageRow( ) }, onOpenPicker = { onAction(ChatAction.OpenReactionPicker(item.messageId)) }, - modifier = Modifier - .align(if (item.isFromSelf) Alignment.TopEnd else Alignment.TopStart) - .offset(y = -QUICK_STRIP_OFFSET), + // Clear of the selection bar's actions at the top. + minTop = WindowInsets.statusBars.asPaddingValues() + .calculateTopPadding() + SELECTION_BAR_CLEARANCE, ) } } @@ -484,7 +488,7 @@ private fun senderNameInset(showsGutter: Boolean): Dp = // doesn't expose its resolved width outward, so the pill row underneath it re-derives the same // fraction of the shared row width instead. private const val BUBBLE_ROW_WIDTH_FRACTION = 0.78f -private val QUICK_STRIP_OFFSET = 12.dp +private val SELECTION_BAR_CLEARANCE = 72.dp private val AFFORDANCE_SIZE = 32.dp private val AFFORDANCE_INSET = 20.dp diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/QuickReactionStrip.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/QuickReactionStrip.kt new file mode 100644 index 0000000000..a3cbe3bf8a --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/QuickReactionStrip.kt @@ -0,0 +1,217 @@ +package com.flipcash.shared.chat.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AddReaction +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties +import com.flipcash.shared.chat.reactions.ReactionStrip + +// Sizes from the iOS strip (node 9779:105563). +private val StripHeight = 55.dp +private val StripMaxWidth = 313.dp +private val ItemSize = 40.dp +private val ItemSpacing = 4.dp +private val Inset = 8.dp +private val AddSize = 38.dp + +/** How far the fade reaches ahead of the "+" before the row draws fully. */ +private val FadeLead = 28.dp + +/** iOS's pre-glass strip surface. */ +private val StripSurface = Color(0xFF303030) + +/** + * The strip a long-press on a reactable, selected bubble shows beside it: up to the 12 entries + * [ReactionStripComposer][com.flipcash.shared.chat.reactions.ReactionStripComposer] composed, + * self-reacted ones highlighted, and a "+" pinned at the trailing end that opens the full picker. + * A tap on an entry both toggles the reaction and clears the selection — [onToggle] is the one + * signal for both. + * + * The capsule grows to fit its entries up to 313dp; past that the emoji scroll under the "+", + * fading out ahead of it. + */ +@Composable +fun QuickReactionStrip( + entries: List, + onToggle: (emoji: String) -> Unit, + onOpenPicker: () -> Unit, + modifier: Modifier = Modifier, +) { + if (entries.isEmpty()) return + + Box( + modifier = modifier + .widthIn(max = StripMaxWidth) + .height(StripHeight) + .clip(CircleShape) + .background(StripSurface), + contentAlignment = Alignment.CenterEnd, + ) { + val density = LocalDensity.current + Row( + modifier = Modifier + .fillMaxHeight() + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .drawWithContent { + drawContent() + val width = size.width + val inset = Inset.toPx() + val solidUntil = width - inset - AddSize.toPx() - FadeLead.toPx() + val clearFrom = width - inset - AddSize.toPx() / 2 + drawRect( + brush = Brush.horizontalGradient( + 0f to Color.Transparent, + (inset / width) to Color.Black, + (solidUntil / width).coerceAtLeast(inset / width) to Color.Black, + (clearFrom / width) to Color.Transparent, + 1f to Color.Transparent, + ), + blendMode = BlendMode.DstIn, + ) + } + .testTag("quick_reaction_scroll") + .horizontalScroll(rememberScrollState()) + // Room to bring the last emoji out from under the "+" and its fade. + .padding(start = Inset, end = Inset + AddSize + FadeLead), + horizontalArrangement = Arrangement.spacedBy(ItemSpacing), + verticalAlignment = Alignment.CenterVertically, + ) { + entries.forEach { entry -> + Box( + modifier = Modifier + .testTag("quick_reaction_${entry.emoji}") + .semantics { selected = entry.highlighted } + .size(ItemSize) + .clip(CircleShape) + .background(if (entry.highlighted) Color.White.copy(alpha = 0.18f) else Color.Transparent) + .combinedClickable(onClick = { onToggle(entry.emoji) }, onLongClick = {}), + contentAlignment = Alignment.Center, + ) { + Text(text = entry.emoji, fontSize = with(density) { 28.dp.toSp() }) + } + } + } + Box( + modifier = Modifier + .padding(end = Inset) + .testTag("quick_reaction_plus") + .semantics { contentDescription = "More reactions" } + .size(AddSize) + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.18f)) + .combinedClickable(onClick = onOpenPicker, onLongClick = {}), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Outlined.AddReaction, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(22.dp), + ) + } + } +} + +/** + * [QuickReactionStrip] in a popup anchored to the bubble this composable sits in, placed the way + * iOS places it: above the bubble with a 16dp gap (below when there's no room under [minTop]), + * hugging the sender's side, and never within 16dp of the window's edges. + */ +@Composable +fun QuickReactionStripPopup( + entries: List, + hugsTrailing: Boolean, + onToggle: (emoji: String) -> Unit, + onOpenPicker: () -> Unit, + minTop: Dp, +) { + val density = LocalDensity.current + val provider = remember(hugsTrailing, minTop, density) { + object : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize, + ): IntOffset = with(density) { + QuickReactionStripPlacement.position( + anchor = anchorBounds, + window = windowSize, + strip = popupContentSize, + hugsTrailing = hugsTrailing, + margin = 16.dp.roundToPx(), + gap = 16.dp.roundToPx(), + minTop = minTop.roundToPx(), + ) + } + } + } + Popup( + popupPositionProvider = provider, + properties = PopupProperties(focusable = false, clippingEnabled = false), + ) { + QuickReactionStrip(entries = entries, onToggle = onToggle, onOpenPicker = onOpenPicker) + } +} + +/** Where [QuickReactionStripPopup] puts the strip, in window pixels. */ +object QuickReactionStripPlacement { + fun position( + anchor: IntRect, + window: IntSize, + strip: IntSize, + hugsTrailing: Boolean, + margin: Int, + gap: Int, + minTop: Int, + ): IntOffset { + val hugged = if (hugsTrailing) { + minOf(anchor.right, window.width - margin) - strip.width + } else { + maxOf(anchor.left, margin) + } + val x = hugged.coerceIn(margin, maxOf(margin, window.width - margin - strip.width)) + val above = anchor.top - gap - strip.height + val y = if (above >= minTop) above else anchor.bottom + gap + return IntOffset(x, y.coerceIn(minTop, maxOf(minTop, window.height - strip.height))) + } +} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionPillRow.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionPillRow.kt index 0736a3160c..0c16ff7abb 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionPillRow.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionPillRow.kt @@ -3,13 +3,9 @@ package com.flipcash.shared.chat.ui import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text @@ -31,7 +27,6 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.flipcash.shared.chat.reactions.ReactionPill -import com.flipcash.shared.chat.reactions.ReactionStrip import com.getcode.theme.CodeTheme /** @@ -270,65 +265,6 @@ fun ReactionPillRow( } } -/** - * The strip a long-press on a reactable, selected bubble shows above it (decision 5): up to the 12 - * entries [ReactionStripComposer][com.flipcash.shared.chat.reactions.ReactionStripComposer] - * composed, self-reacted ones highlighted, plus a trailing "+" that opens the full picker. A tap - * on an entry both toggles the reaction and clears the selection — [onToggle] is the one signal - * for both, same as the pill row's tap is a plain toggle without one. - * - * Horizontally scrolling rather than wrapping: it draws above a single bubble, where wrapping to a - * second line would push it further from the message it's reacting to. - */ -@Composable -fun QuickReactionStrip( - entries: List, - onToggle: (emoji: String) -> Unit, - onOpenPicker: () -> Unit, - modifier: Modifier = Modifier, -) { - if (entries.isEmpty()) return - - Row( - modifier = modifier - .background(color = Color.Black.copy(alpha = 0.72f), shape = RoundedCornerShape(percent = 50)) - .horizontalScroll(rememberScrollState()) - .padding(horizontal = 6.dp, vertical = 6.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - entries.forEach { entry -> - Box( - modifier = Modifier - .testTag("quick_reaction_${entry.emoji}") - .semantics { selected = entry.highlighted } - .size(32.dp) - .let { base -> - if (entry.highlighted) { - base.background(Color.White.copy(alpha = 0.16f), CircleShape) - } else { - base - } - } - .combinedClickable(onClick = { onToggle(entry.emoji) }, onLongClick = {}), - contentAlignment = Alignment.Center, - ) { - Text(text = entry.emoji, fontSize = 20.sp) - } - } - Box( - modifier = Modifier - .testTag("quick_reaction_plus") - .size(32.dp) - .background(Color.White.copy(alpha = 0.06f), CircleShape) - .combinedClickable(onClick = onOpenPicker, onLongClick = {}), - contentAlignment = Alignment.Center, - ) { - Text(text = "+", fontSize = 18.sp) - } - } -} - @Composable private fun ReactionPillChip( pill: ReactionPill, diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/QuickReactionStripPlacementTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/QuickReactionStripPlacementTest.kt new file mode 100644 index 0000000000..9562c85b6e --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/QuickReactionStripPlacementTest.kt @@ -0,0 +1,69 @@ +package com.flipcash.shared.chat.ui + +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import org.junit.Test +import kotlin.test.assertEquals + +/** [QuickReactionStripPlacement]: iOS's strip placement, beside the long-pressed bubble. */ +class QuickReactionStripPlacementTest { + + private val window = IntSize(1000, 2000) + private val strip = IntSize(600, 100) + + private fun place(anchor: IntRect, hugsTrailing: Boolean, strip: IntSize = this.strip) = + QuickReactionStripPlacement.position( + anchor = anchor, + window = window, + strip = strip, + hugsTrailing = hugsTrailing, + margin = 40, + gap = 40, + minTop = 300, + ) + + @Test + fun `sits above the bubble, leading edges aligned, for an incoming message`() { + assertEquals( + IntOffset(120, 1500 - 40 - 100), + place(IntRect(left = 120, top = 1500, right = 500, bottom = 1600), hugsTrailing = false), + ) + } + + @Test + fun `hugs the trailing edge of an outgoing bubble`() { + assertEquals( + IntOffset(960 - 600, 1360), + place(IntRect(left = 500, top = 1500, right = 960, bottom = 1600), hugsTrailing = true), + ) + } + + @Test + fun `keeps the window margin when the bubble runs to the edge`() { + assertEquals( + IntOffset(40, 1360), + place(IntRect(left = 0, top = 1500, right = 400, bottom = 1600), hugsTrailing = false), + ) + assertEquals( + IntOffset(1000 - 40 - 600, 1360), + place(IntRect(left = 500, top = 1500, right = 1000, bottom = 1600), hugsTrailing = true), + ) + } + + @Test + fun `an incoming strip that would overflow the trailing margin slides back in`() { + assertEquals( + IntOffset(1000 - 40 - 600, 1360), + place(IntRect(left = 700, top = 1500, right = 900, bottom = 1600), hugsTrailing = false), + ) + } + + @Test + fun `goes below the bubble when there is no room above`() { + assertEquals( + IntOffset(120, 600 + 40), + place(IntRect(left = 120, top = 350, right = 500, bottom = 600), hugsTrailing = false), + ) + } +} From aaf51f0098a79736d927aaaf8a76c9f7a542df17 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 19:11:08 -0400 Subject: [PATCH 46/58] fix(chat): leave selection when the strip's + opens the picker The strip stayed on screen over the picker because the message stayed selected. iOS dismisses its context menu before presenting the picker; this does the same. --- .../messenger/internal/screens/components/MessageRow.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt index 5ee0a323b2..f2157c3f76 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt @@ -338,7 +338,12 @@ internal fun MessageRow( ) ) }, - onOpenPicker = { onAction(ChatAction.OpenReactionPicker(item.messageId)) }, + // Leave selection first, as iOS dismisses its context + // menu, so the strip doesn't float over the picker. + onOpenPicker = { + onAction(ChatAction.ClearSelection) + onAction(ChatAction.OpenReactionPicker(item.messageId)) + }, // Clear of the selection bar's actions at the top. minTop = WindowInsets.statusBars.asPaddingValues() .calculateTopPadding() + SELECTION_BAR_CLEARANCE, From 0f761d0f124802e02f935f52fc345537b999b013 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 19:17:14 -0400 Subject: [PATCH 47/58] fix(chat): size reaction pills and their + as on iOS Pills and the "N more" pill never applied their 28dp height, so they shrank to the text and sat shorter than "+", and their text took Material's dark content color, leaving the count unreadable on the dark fill. They now match iOS: 28dp tall, a 15sp emoji and a 13sp medium count in textMain, 4dp apart. "+" uses the strip's add-reaction icon on a white-18% circle, and both "+" buttons are labelled "Add reaction" for TalkBack. --- .../core/src/main/res/values/strings.xml | 1 + .../shared/chat/ui/QuickReactionStrip.kt | 4 +- .../shared/chat/ui/ReactionPillRow.kt | 46 +++++++++++++++---- .../shared/chat/ui/ReactionPillRowTest.kt | 12 +++++ 4 files changed, 54 insertions(+), 9 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index deb72fcb6d..d02127ecdd 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1010,6 +1010,7 @@ Reactions Search Clear search + Add reaction Unknown Contact Allow Full Contact Access diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/QuickReactionStrip.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/QuickReactionStrip.kt index a3cbe3bf8a..0ecc83e616 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/QuickReactionStrip.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/QuickReactionStrip.kt @@ -44,6 +44,8 @@ import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupPositionProvider import androidx.compose.ui.window.PopupProperties import com.flipcash.shared.chat.reactions.ReactionStrip +import androidx.compose.ui.res.stringResource +import com.flipcash.core.R // Sizes from the iOS strip (node 9779:105563). private val StripHeight = 55.dp @@ -143,7 +145,7 @@ fun QuickReactionStrip( ) { Icon( imageVector = Icons.Outlined.AddReaction, - contentDescription = null, + contentDescription = stringResource(R.string.action_addReaction), tint = Color.White, modifier = Modifier.size(22.dp), ) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionPillRow.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionPillRow.kt index 0c16ff7abb..fee2802743 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionPillRow.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionPillRow.kt @@ -3,11 +3,17 @@ package com.flipcash.shared.chat.ui import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AddReaction +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -21,13 +27,15 @@ import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.flipcash.shared.chat.reactions.ReactionPill import com.getcode.theme.CodeTheme +import androidx.compose.ui.res.stringResource +import com.flipcash.core.R /** * The N-more collapse the pill row uses under a bubble (decision 2): pack pills onto up to @@ -295,10 +303,23 @@ private fun ReactionPillChip( } } .combinedClickable(onClick = onClick ?: {}, onLongClick = onLongClick) + .height(height) .padding(horizontal = horizontalPadding), contentAlignment = Alignment.Center, ) { - Text(text = "${pill.emoji} ${pill.count}", fontSize = 13.sp, textAlign = TextAlign.Center) + // iOS's pill: a 15pt emoji and a 13pt medium count in textMain, 4pt apart. + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = pill.emoji, fontSize = 15.sp) + Text( + text = pill.count.toString(), + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = CodeTheme.colors.textMain, + ) + } } } @@ -320,10 +341,16 @@ private fun MorePillChip( shape = RoundedCornerShape(percent = 50), ) .let { base -> onClick?.let { base.combinedClickable(onClick = it, onLongClick = {}) } ?: base } + .height(height) .padding(horizontal = horizontalPadding), contentAlignment = Alignment.Center, ) { - Text(text = "$count more", fontSize = 13.sp) + Text( + text = "$count more", + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = CodeTheme.colors.textMain, + ) } } @@ -333,13 +360,16 @@ private fun PlusChip(size: Dp, onClick: () -> Unit) { modifier = Modifier .testTag("reaction_pill_plus") .size(size) - .background( - color = Color.White.copy(alpha = 0.06f), - shape = RoundedCornerShape(percent = 50), - ) + .background(color = Color.White.copy(alpha = 0.18f), shape = CircleShape) .combinedClickable(onClick = onClick, onLongClick = {}), contentAlignment = Alignment.Center, ) { - Text(text = "+", fontSize = 15.sp) + // The strip's add-reaction icon, as iOS reuses its strip asset here. + Icon( + imageVector = Icons.Outlined.AddReaction, + contentDescription = stringResource(R.string.action_addReaction), + tint = CodeTheme.colors.textMain, + modifier = Modifier.size(20.dp), + ) } } diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ReactionPillRowTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ReactionPillRowTest.kt index 7c91e93847..56dc500bec 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ReactionPillRowTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ReactionPillRowTest.kt @@ -83,6 +83,18 @@ class ReactionPillRowTest { .assertHeightIsEqualTo(28.dp) } + @Test + fun `pills and the N more pill are 28dp tall, level with plus`() { + val pills = listOf( + pill("😀"), pill("😁"), pill("😂"), pill("😃"), + pill("😄"), pill("😅"), pill("😆"), pill("😇"), + ) + setRow(pills = pills, width = 90.dp) + + composeTestRule.onNodeWithTag("reaction_pill_😀").assertHeightIsEqualTo(28.dp) + composeTestRule.onNodeWithTag("reaction_pill_more").assertHeightIsEqualTo(28.dp) + } + @Test fun `tapping a pill toggles that emoji`() { val toggled = mutableListOf() From 8e7f4d533bae576fc6585851e40386242d36ae5d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 19:20:20 -0400 Subject: [PATCH 48/58] fix(messenger): slide the picker's category indicator and let it drag, as on iOS --- .../internal/screens/ReactionPickerSheet.kt | 143 ++++++++++++++---- 1 file changed, 115 insertions(+), 28 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactionPickerSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactionPickerSheet.kt index f169e47dff..b10f6698eb 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactionPickerSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactionPickerSheet.kt @@ -1,6 +1,21 @@ package com.flipcash.app.messenger.internal.screens -import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.spring +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.offset +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.unit.IntOffset +import kotlin.math.roundToInt import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -212,7 +227,7 @@ private fun EmojiGrid( .hazeSource(hazeState), contentPadding = PaddingValues( top = 4.dp, - bottom = if (showBar) 45.dp + 24.dp else 16.dp, + bottom = if (showBar) CATEGORY_BAR_HEIGHT + 24.dp else 16.dp, ), ) { items(rows, key = { row -> @@ -288,8 +303,10 @@ private fun RowScope.EmojiCell(entry: EmojiCatalogEntry, onSelected: (String) -> /** * The floating jump bar to each category, a glass capsule over the grid (node 9768:1624), frosted * with a Haze blur of the grid scrolling beneath it — Android's analogue of iOS's `.glassEffect` - * (see [com.flipcash.app.core.ui.NavigationBar] for the same pattern). Unlike iOS, there is no - * drag-across-the-bar gesture — only a tap per slot (a deliberate scope cut; see the chunk report). + * (see [com.flipcash.app.core.ui.NavigationBar] for the same pattern). The selected category sits on + * its own capsule indicator, which springs between slots. As on iOS, the indicator can be pressed and + * dragged across the bar; [onSelect] fires only once it's let go over a category, so a plain tap is + * the same gesture with no travel. */ @Composable private fun CategoryBar( @@ -305,47 +322,117 @@ private fun CategoryBar( backgroundColor(backdrop) colorEffects(listOf(HazeColorEffect.tint(backdrop.copy(alpha = 0.72f)))) } + val haptics = LocalHapticFeedback.current + val density = LocalDensity.current + val selectedIndex = sections.indexOfFirst { it.id == selected }.coerceAtLeast(0) + + // Where the finger is along the bar while it drags the indicator, null otherwise. + var dragX by remember { mutableStateOf(null) } Box( modifier = modifier .fillMaxWidth() .padding(horizontal = 30.dp) - .height(45.dp) + .height(CATEGORY_BAR_HEIGHT) .clip(CircleShape) .hazeBlur(HazeInput.Sources(hazeState), liquidGlass), ) { - Row( + BoxWithConstraints( modifier = Modifier .fillMaxSize() - .padding(horizontal = 6.dp), + .padding(horizontal = 6.dp) + .pointerInput(sections) { + val count = sections.size + fun indexAt(x: Float): Int = + (x / (size.width.toFloat() / count)).toInt().coerceIn(0, count - 1) + awaitEachGesture { + val down = awaitFirstDown() + down.consume() + dragX = down.position.x + var hovered = indexAt(down.position.x) + var lastX = down.position.x + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == down.id } ?: break + if (!change.pressed) { + lastX = change.position.x + change.consume() + break + } + change.consume() + lastX = change.position.x + dragX = lastX + val index = indexAt(lastX) + if (index != hovered) { + hovered = index + haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) + } + } + dragX = null + onSelect(sections[indexAt(lastX)].id) + } + }, ) { - for (section in sections) { - val isSelected = section.id == selected - val indicatorColor by animateColorAsState( - targetValue = if (isSelected) { - CodeTheme.colors.textMain.copy(alpha = 0.14f) - } else { - Color.Transparent - }, - label = "categoryBarIndicator", - ) - Box( - modifier = Modifier - .weight(1f) - .fillMaxSize() - .clip(CircleShape) - .background(indicatorColor) - .unboundedClickable { onSelect(section.id) } - .semantics { contentDescription = section.title }, - contentAlignment = Alignment.Center, - ) { - Text(text = section.entries.firstOrNull()?.emoji.orEmpty(), fontSize = 20.sp) + val barWidth = constraints.maxWidth.toFloat() + val slot = barWidth / sections.size.coerceAtLeast(1) + val indicatorWidth = with(density) { minOf(slot, (INDICATOR_HEIGHT + 6.dp).toPx()) } + val restingX = slot * (selectedIndex + 0.5f) + val targetX = dragX?.coerceIn(slot / 2, barWidth - slot / 2) ?: restingX + // Follow the finger 1:1 while dragging; only the press, release and selection animate. + val indicatorX by animateFloatAsState( + targetValue = targetX, + animationSpec = if (dragX != null) snap() else settleSpring(), + label = "categoryIndicatorX", + ) + val indicatorScale by animateFloatAsState( + targetValue = if (dragX == null) 1f else 1.15f, + animationSpec = settleSpring(), + label = "categoryIndicatorScale", + ) + + Box( + modifier = Modifier + .align(Alignment.CenterStart) + .offset { IntOffset((indicatorX - indicatorWidth / 2).roundToInt(), 0) } + .size(width = with(density) { indicatorWidth.toDp() }, height = INDICATOR_HEIGHT) + .graphicsLayer { + scaleX = indicatorScale + scaleY = indicatorScale + } + .clip(CircleShape) + .background(CodeTheme.colors.textMain.copy(alpha = 0.14f)), + ) + + Row(modifier = Modifier.fillMaxSize()) { + sections.forEachIndexed { index, section -> + Box( + modifier = Modifier + .weight(1f) + .fillMaxSize() + .semantics { + contentDescription = section.title + this.selected = index == selectedIndex + onClick { + onSelect(section.id) + true + } + }, + contentAlignment = Alignment.Center, + ) { + Text(text = section.entries.firstOrNull()?.emoji.orEmpty(), fontSize = 20.sp) + } } } } } } +// iOS's `.spring(duration: 0.35, bounce: 0.2)`. +private fun settleSpring() = spring(dampingRatio = 0.8f, stiffness = 320f) + +private val CATEGORY_BAR_HEIGHT = 45.dp +private val INDICATOR_HEIGHT = 38.dp + @Composable private fun SearchField(state: TextFieldState, modifier: Modifier = Modifier) { Row( From b421eac7b7c01a23fa029a21d05bb7d61cc21c86 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 19:52:03 -0400 Subject: [PATCH 49/58] fix(messenger): keep the picker's category bar on screen once the sheet can expand --- .../internal/screens/ReactionPickerSheet.kt | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactionPickerSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactionPickerSheet.kt index b10f6698eb..3f71ed5718 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactionPickerSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactionPickerSheet.kt @@ -10,7 +10,10 @@ import androidx.compose.foundation.layout.offset import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.semantics.onClick import androidx.compose.ui.semantics.selected @@ -48,6 +51,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember @@ -219,7 +223,21 @@ private fun EmojiGrid( } val selectedCategory = pendingCategory ?: visibleCategory ?: sections.firstOrNull()?.id - Box(modifier = Modifier.fillMaxSize().navigationBarsPadding()) { + // How far the sheet runs below the bottom of the window. Once the sheet can expand it is laid out + // taller than the part on screen, so pinning the bar to the sheet's own bottom would push it off + // screen; lifting it by this much keeps it hugging the screen at either detent and mid-drag. + val windowHeight = LocalWindowInfo.current.containerSize.height + var belowWindowPx by remember { mutableIntStateOf(0) } + + Box( + modifier = Modifier + .fillMaxSize() + .onGloballyPositioned { coordinates -> + val bottom = coordinates.positionInWindow().y + coordinates.size.height + belowWindowPx = (bottom - windowHeight).roundToInt().coerceAtLeast(0) + } + .navigationBarsPadding(), + ) { LazyColumn( state = gridState, modifier = Modifier @@ -280,6 +298,7 @@ private fun EmojiGrid( }, modifier = Modifier .align(Alignment.BottomCenter) + .offset { IntOffset(0, -belowWindowPx) } .padding(bottom = 4.dp), ) } From d079feceaf64321c6afc3fe500521710f96ee1ae Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 20:32:15 -0400 Subject: [PATCH 50/58] fix(messenger): move the picker's category bar in step with the sheet The bar's lift came from onGloballyPositioned, which only sees the sheet's new position after layout, so the bar trailed the sheet by a frame and jumped while it was dragged. The sheet host now exposes LocalSheetOverhang, read from the sheet state's drag offset, and the bar reads it at placement so both move in the same frame. --- .../internal/screens/ReactionPickerSheet.kt | 26 +++++-------------- .../scenes/ModalBottomSheetSceneStrategy.kt | 12 +++++++++ .../com/getcode/ui/utils/SheetExpansion.kt | 11 ++++++++ 3 files changed, 30 insertions(+), 19 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactionPickerSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactionPickerSheet.kt index 3f71ed5718..6d5e25bdab 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactionPickerSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactionPickerSheet.kt @@ -10,10 +10,7 @@ import androidx.compose.foundation.layout.offset import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.semantics.onClick import androidx.compose.ui.semantics.selected @@ -51,7 +48,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember @@ -76,6 +72,7 @@ import com.getcode.libs.emojis.reactions.EmojiPickerModel import com.getcode.theme.CodeTheme import com.getcode.ui.core.unboundedClickable import com.getcode.ui.theme.CodeCircularProgressIndicator +import com.getcode.ui.utils.LocalSheetOverhang import com.getcode.ui.utils.AllowSheetExpansionWhenScrollable import dev.chrisbanes.haze.HazeInput import dev.chrisbanes.haze.HazeState @@ -223,21 +220,12 @@ private fun EmojiGrid( } val selectedCategory = pendingCategory ?: visibleCategory ?: sections.firstOrNull()?.id - // How far the sheet runs below the bottom of the window. Once the sheet can expand it is laid out - // taller than the part on screen, so pinning the bar to the sheet's own bottom would push it off - // screen; lifting it by this much keeps it hugging the screen at either detent and mid-drag. - val windowHeight = LocalWindowInfo.current.containerSize.height - var belowWindowPx by remember { mutableIntStateOf(0) } + // An expandable sheet is laid out at its expanded height and slid down, so its bottom sits off + // screen until it's fully up. Lifting the bar by that overhang keeps it hugging the screen at + // either detent and mid-drag; it's read at placement so it moves in the same frame as the sheet. + val sheetOverhang = LocalSheetOverhang.current - Box( - modifier = Modifier - .fillMaxSize() - .onGloballyPositioned { coordinates -> - val bottom = coordinates.positionInWindow().y + coordinates.size.height - belowWindowPx = (bottom - windowHeight).roundToInt().coerceAtLeast(0) - } - .navigationBarsPadding(), - ) { + Box(modifier = Modifier.fillMaxSize().navigationBarsPadding()) { LazyColumn( state = gridState, modifier = Modifier @@ -298,7 +286,7 @@ private fun EmojiGrid( }, modifier = Modifier .align(Alignment.BottomCenter) - .offset { IntOffset(0, -belowWindowPx) } + .offset { IntOffset(0, -sheetOverhang().roundToInt()) } .padding(bottom = 4.dp), ) } diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/scenes/ModalBottomSheetSceneStrategy.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/scenes/ModalBottomSheetSceneStrategy.kt index 03665b4042..a8ebd3c7a9 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/scenes/ModalBottomSheetSceneStrategy.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/scenes/ModalBottomSheetSceneStrategy.kt @@ -18,6 +18,7 @@ import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -27,6 +28,7 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.platform.LocalView @@ -54,6 +56,7 @@ import com.getcode.navigation.scrim.ScrimOverlay import com.getcode.theme.CodeTheme import com.getcode.ui.core.noRippleClickable import com.getcode.ui.utils.LocalSheetExpansionState +import com.getcode.ui.utils.LocalSheetOverhang import com.getcode.ui.utils.LocalSheetGesturesState import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @@ -244,6 +247,13 @@ internal class ModalBottomSheetScene constructor( val composeScope = rememberCoroutineScope() + // The sheet's own height only changes on relayout, never mid-drag, so measure it once + // and let [LocalSheetOverhang] subtract the live drag offset at placement time. + var sheetHeightPx by remember { mutableFloatStateOf(0f) } + val sheetOverhang = remember(sheetState) { + { (sheetHeightPx - sheetState.offset).coerceAtLeast(0f) } + } + val dismiss = { hide: Boolean -> if (hide && sheetState.currentDetent != SheetDetent.Hidden) { composeScope.launch { @@ -279,6 +289,7 @@ internal class ModalBottomSheetScene constructor( allowDismiss = enabled && !navigator.sheetDragDisabled }, LocalSheetExpansionState provides setContentOverflows, + LocalSheetOverhang provides sheetOverhang, LocalScrimController provides scrim, ) { BackHandler(enabled = effectiveProperties.dismissOnBackPress) { @@ -333,6 +344,7 @@ internal class ModalBottomSheetScene constructor( Sheet( modifier = Modifier .fillMaxWidth() + .onSizeChanged { sheetHeightPx = it.height.toFloat() } // Rounded corners read as a card lifted over the screen behind it. // A fullscreen sheet has no screen behind it to be lifted over, and // the curves would only carve two notches out of the display's own diff --git a/ui/navigation/src/main/kotlin/com/getcode/ui/utils/SheetExpansion.kt b/ui/navigation/src/main/kotlin/com/getcode/ui/utils/SheetExpansion.kt index 1903beca80..29830823bd 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/ui/utils/SheetExpansion.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/ui/utils/SheetExpansion.kt @@ -36,3 +36,14 @@ fun AllowSheetExpansionWhenScrollable(scrollState: LazyListState) { onDispose { setExpandable(false) } } } + +/** + * How far, in px, the host sheet currently runs below the bottom of its container. + * + * A [com.getcode.navigation.HalfSheet] that can expand is laid out at its expanded height and slid + * down, so at the resting detent (and mid-drag) its bottom is off screen. Content that pins + * something to the bottom of the screen lifts it by this much. Read it in a placement or draw + * lambda, not in composition: it tracks the sheet's drag offset, and reading it there moves with + * the sheet in the same frame instead of recomposing every frame. + */ +val LocalSheetOverhang = staticCompositionLocalOf<() -> Float> { { 0f } } From 0613fe59bce0736c7ce1f67ee468959bd346dc23 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 22:10:53 -0400 Subject: [PATCH 51/58] fix(chat): grow the quick reaction strip in as iOS does The strip popped in at full size in its own popup window, a beat out of step with the bubble's raise. It now enters as the iOS strip does: the capsule widens out of the side it hugs with "+" on its growing edge, and the emoji scale in one after another, settling in about 200ms. Everything animates in the draw phase, so the popup never resizes. --- .../shared/chat/ui/QuickReactionStrip.kt | 95 ++++++++++++++++++- 1 file changed, 92 insertions(+), 3 deletions(-) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/QuickReactionStrip.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/QuickReactionStrip.kt index 0ecc83e616..5903328cc3 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/QuickReactionStrip.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/QuickReactionStrip.kt @@ -1,5 +1,7 @@ package com.flipcash.shared.chat.ui +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.spring import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.horizontalScroll @@ -18,6 +20,8 @@ import androidx.compose.material.icons.outlined.AddReaction import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -27,8 +31,16 @@ import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.unit.Density import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.selected @@ -43,6 +55,8 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupPositionProvider import androidx.compose.ui.window.PopupProperties +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import com.flipcash.shared.chat.reactions.ReactionStrip import androidx.compose.ui.res.stringResource import com.flipcash.core.R @@ -58,6 +72,13 @@ private val AddSize = 38.dp /** How far the fade reaches ahead of the "+" before the row draws fully. */ private val FadeLead = 28.dp +/** Gap between one emoji starting to pop in and the next, as in the iOS strip's entrance. */ +private const val ItemStaggerMillis = 18L + +/** Springs matched by eye to the iOS entrance, which settles in about 200ms. */ +private val GrowSpring = spring(dampingRatio = 0.9f, stiffness = 900f) +private val PopSpring = spring(dampingRatio = 0.65f, stiffness = 1200f) + /** iOS's pre-glass strip surface. */ private val StripSurface = Color(0xFF303030) @@ -70,6 +91,11 @@ private val StripSurface = Color(0xFF303030) * * The capsule grows to fit its entries up to 313dp; past that the emoji scroll under the "+", * fading out ahead of it. + * + * It enters as iOS's does: the capsule widens out of the side it hugs ([growsFromEnd] for the + * trailing side), the "+" riding its growing edge, and the emoji pop in one after another from + * that side. All of it animates in the draw phase, so the strip's measured size never changes — + * inside a popup, a changing size would resize the window every frame. */ @Composable fun QuickReactionStrip( @@ -77,14 +103,41 @@ fun QuickReactionStrip( onToggle: (emoji: String) -> Unit, onOpenPicker: () -> Unit, modifier: Modifier = Modifier, + growsFromEnd: Boolean = false, ) { if (entries.isEmpty()) return + val grow = remember { Animatable(0f) } + val pops = remember(entries.size) { List(entries.size) { Animatable(0f) } } + LaunchedEffect(pops) { + launch { grow.animateTo(1f, GrowSpring) } + val order = if (growsFromEnd) pops.asReversed() else pops + order.forEachIndexed { i, pop -> + launch { + delay(i * ItemStaggerMillis) + pop.animateTo(1f, PopSpring) + } + } + } + // How far the capsule's growing edge still has to travel, read in the draw phase only. + fun remaining(fullWidth: Float, minWidth: Float): Float = + (fullWidth - minWidth).coerceAtLeast(0f) * (1f - grow.value).coerceAtLeast(0f) + + val stripWidth = remember { mutableFloatStateOf(0f) } + val rtl = LocalLayoutDirection.current == LayoutDirection.Rtl + Box( modifier = modifier .widthIn(max = StripMaxWidth) .height(StripHeight) - .clip(CircleShape) + .onSizeChanged { stripWidth.floatValue = it.width.toFloat() } + .graphicsLayer { + val minWidth = StripHeight.toPx() + shape = RevealCapsule(remaining(size.width, minWidth), growsFromEnd) + clip = true + // Fully opaque a third of the way through the growth, as the iOS capsule is. + alpha = (grow.value * 3f).coerceIn(0f, 1f) + } .background(StripSurface), contentAlignment = Alignment.CenterEnd, ) { @@ -117,9 +170,15 @@ fun QuickReactionStrip( horizontalArrangement = Arrangement.spacedBy(ItemSpacing), verticalAlignment = Alignment.CenterVertically, ) { - entries.forEach { entry -> + entries.forEachIndexed { index, entry -> Box( modifier = Modifier + .graphicsLayer { + val pop = pops[index].value + scaleX = pop + scaleY = pop + alpha = pop.coerceIn(0f, 1f) + } .testTag("quick_reaction_${entry.emoji}") .semantics { selected = entry.highlighted } .size(ItemSize) @@ -134,6 +193,14 @@ fun QuickReactionStrip( } Box( modifier = Modifier + .graphicsLayer { + // Ride the growing edge when that edge is the trailing one; when the capsule + // grows out of the trailing side the "+" is already where it ends up. + if (!growsFromEnd) { + val travel = remaining(stripWidth.floatValue, StripHeight.toPx()) + translationX = if (rtl) travel else -travel + } + } .padding(end = Inset) .testTag("quick_reaction_plus") .semantics { contentDescription = "More reactions" } @@ -191,7 +258,29 @@ fun QuickReactionStripPopup( popupPositionProvider = provider, properties = PopupProperties(focusable = false, clippingEnabled = false), ) { - QuickReactionStrip(entries = entries, onToggle = onToggle, onOpenPicker = onOpenPicker) + QuickReactionStrip( + entries = entries, + onToggle = onToggle, + onOpenPicker = onOpenPicker, + growsFromEnd = hugsTrailing, + ) + } +} + +/** + * A capsule over the strip's bounds with [remaining] pixels cut off the growing side: the trailing + * side normally, the leading one when [fromEnd] (the capsule grows out of the trailing side). + */ +private class RevealCapsule( + private val remaining: Float, + private val fromEnd: Boolean, +) : Shape { + override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline { + val growsRight = fromEnd == (layoutDirection == LayoutDirection.Rtl) + val left = if (growsRight) 0f else remaining + val right = if (growsRight) size.width - remaining else size.width + val radius = CornerRadius(size.height / 2f) + return Outline.Rounded(RoundRect(left, 0f, right, size.height, radius)) } } From bb0bb07d21f30f01ea321f8ee8329fda53df1362 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 22:36:18 -0400 Subject: [PATCH 52/58] fix(chat): keep pills under the backdrop and enter the strip with the lift Selecting a message hid every row's pill row, so the pills vanished from the whole transcript while one bubble was raised. They now stay, blurred and dimmed with their row, and ignore touches like the rest of it. The strip was built only after the selection landed: a glyph check over the first catalog category and a DataStore read, then a second state update. The popup therefore trailed the lift. Those inputs are now kept ready from init and refreshed after each toggle, and the strip is built as the message is selected and carried in on the same event. --- .../app/messenger/internal/ChatViewModel.kt | 71 +++++++++++++++---- .../internal/screens/MessengerScreen.kt | 5 +- .../internal/screens/components/MessageRow.kt | 21 +++++- 3 files changed, 82 insertions(+), 15 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 41a613a102..b20e508889 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -100,6 +100,7 @@ import com.flipcash.shared.chat.reactions.SelfReaction import com.getcode.libs.emojis.reactions.EmojiCatalogLoader import com.getcode.libs.emojis.reactions.EmojiDrawability import com.getcode.libs.emojis.reactions.EmojiPickerModel +import com.getcode.libs.emojis.reactions.RecentReactions import com.getcode.libs.emojis.reactions.RecentReactionsStore import com.flipcash.shared.chat.canReact import com.flipcash.shared.chat.resolveCapabilities @@ -584,7 +585,12 @@ internal class ChatViewModel @Inject constructor( data class MessagePolicyChanged(val policy: MessagePolicy) : Event /** Selects [bubble], or leaves selection mode if it is already the selected one. */ - data class ToggleMessageSelection(val bubble: ChatListItem.ContentBubble) : Event + /** [quickReactionStrip] is built ahead by [ChatViewModel.quickReactionStripFor], so the + * strip lands in the same update as the selection; empty leaves it to the strip flow. */ + data class ToggleMessageSelection( + val bubble: ChatListItem.ContentBubble, + val quickReactionStrip: List = emptyList(), + ) : Event data object ClearMessageSelection : Event // The message actions carry what they act on rather than reading it back off the selection: @@ -716,6 +722,9 @@ internal class ChatViewModel @Inject constructor( .flatMapLatest { chatCoordinator.observeChatReactions(it) } .stateIn(viewModelScope, SharingStarted.Eagerly, emptyMap()) + /** Declared before `init`, which starts filling it. See [StripInputs]. */ + private val stripInputs = MutableStateFlow(null) + /** * Null for a DM, a map for a group — the distinction the transcript needs, because a DM's * counterparty is already named in the title bar and attributing each of their bubbles would be @@ -1839,12 +1848,59 @@ internal class ChatViewModel @Inject constructor( .launchIn(viewModelScope) } + /** + * What the quick strip is built from, other than the message's own reactions. Kept ready ahead + * of a long-press so the selection reducer can build the strip in the same state update that + * lifts the bubble; built on demand, the strip trailed the lift by the catalog's glyph check + * and a DataStore read. Refreshed at init and after each toggle, which is what moves recents. + */ + private class StripInputs( + val recentStats: Map, + val catalogFill: List, + val undrawable: Set, + ) + + private suspend fun refreshStripInputs(): StripInputs { + val catalog = emojiCatalogLoader.load() + val fill = catalog.firstCategoryEntries.map { it.emoji } + val undrawable = withContext(Dispatchers.Default) { + fill.filterNot { EmojiDrawability.isDrawable(it) }.toSet() + } + return StripInputs( + recentStats = recentReactionsStore.stats(), + catalogFill = fill, + undrawable = undrawable, + ).also { stripInputs.value = it } + } + + /** + * The quick strip for [bubble], from the inputs already in hand, or empty if they have not + * loaded yet (the strip flow fills it in then). Called as the bubble is selected, so the strip + * enters with the lift instead of a round trip behind it. + */ + fun quickReactionStripFor(bubble: ChatListItem.ContentBubble): List { + if (!bubble.canReact) return emptyList() + val inputs = stripInputs.value ?: return emptyList() + return composeStrip(inputs, reactionOverlay.value[bubble.messageId]?.selfReactions.orEmpty()) + } + + private fun composeStrip(inputs: StripInputs, selfReactions: List) = + ReactionStripComposer.compose( + recentStats = inputs.recentStats, + selfReactions = selfReactions, + catalogFillSource = inputs.catalogFill, + undrawable = inputs.undrawable, + ) + private fun initMessageActionHandlers() { + viewModelScope.launch { refreshStripInputs() } + eventFlow.filterIsInstance() .onEach { event -> val chatId = stateFlow.value.chatId ?: return@onEach viewModelScope.launch { chatCoordinator.toggleReaction(chatId, event.messageId, event.emoji) + refreshStripInputs() } } .launchIn(viewModelScope) @@ -1868,17 +1924,7 @@ internal class ChatViewModel @Inject constructor( val entries = if (selfReactions.isEmpty() && stateFlow.value.selection == null) { emptyList() } else { - val catalog = emojiCatalogLoader.load() - val undrawable = catalog.firstCategoryEntries - .map { it.emoji } - .filterNot { EmojiDrawability.isDrawable(it) } - .toSet() - ReactionStripComposer.compose( - recentStats = recentReactionsStore.stats(), - selfReactions = selfReactions, - catalogFillSource = catalog.firstCategoryEntries.map { it.emoji }, - undrawable = undrawable, - ) + composeStrip(stripInputs.value ?: refreshStripInputs(), selfReactions) } dispatchEvent(Event.QuickReactionStripComposed(entries)) } @@ -2740,6 +2786,7 @@ internal class ChatViewModel @Inject constructor( } state.copy( selection = selected, + quickReactionStrip = if (selected != null) event.quickReactionStrip else emptyList(), confirmingDelete = false, ) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index d690356843..80fda0d32e 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -127,7 +127,10 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { is ChatAction.ToggleSelection -> { viewModel.dispatchEvent( - ChatViewModel.Event.ToggleMessageSelection(action.bubble) + ChatViewModel.Event.ToggleMessageSelection( + bubble = action.bubble, + quickReactionStrip = viewModel.quickReactionStripFor(action.bubble), + ) ) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt index f2157c3f76..d76bdf3fa2 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt @@ -44,6 +44,8 @@ import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -353,8 +355,14 @@ internal fun MessageRow( // Same width as the bubble above it (decision 2) — matched here // against the same fraction MessageBubble sizes a text/reply/deleted // bubble to, since the row doesn't expose its resolved width outward. - if (!selecting && item.reactionPills.isNotEmpty()) { - BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + // Kept on screen while selecting, as iOS keeps them under its + // backdrop, but inert like every other target on the row. + if (item.reactionPills.isNotEmpty()) { + BoxWithConstraints( + modifier = Modifier + .fillMaxWidth() + .addIf(selecting) { Modifier.blockPointerInput() }, + ) { ReactionPillRow( pills = item.reactionPills, canReact = item.canReact, @@ -566,3 +574,12 @@ internal fun startsSenderRun(current: ChatListItem.ContentBubble, older: ChatLis val olderBubble = older as? ChatListItem.ContentBubble ?: return true return olderBubble.authorId != author } + +/** Swallows every press before the children see it, so their own targets never fire. */ +private fun Modifier.blockPointerInput(): Modifier = pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + awaitPointerEvent(PointerEventPass.Initial).changes.forEach { it.consume() } + } + } +} From 71d112f9f6961c7a74459458588d342e111a34ce Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 22:45:02 -0400 Subject: [PATCH 53/58] fix(chat): place the reaction strip below the measured top bar --- .../messenger/internal/screens/MessengerScreen.kt | 1 + .../internal/screens/components/MessageList.kt | 8 ++++++-- .../internal/screens/components/MessageRow.kt | 14 ++++++++++---- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index 80fda0d32e..7de666e6b9 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -285,6 +285,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { linkCardResolution = viewModel.linkCardResolution, canViewProfile = state.canViewProfile, onJumpConsumed = { viewModel.dispatchEvent(ChatViewModel.Event.JumpConsumed) }, + topBarBottom = barHeight, ) } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index 7b7e5da032..76c2f7e5f2 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -7,14 +7,14 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListLayoutInfo import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -33,6 +33,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.repeatOnLifecycle @@ -78,6 +80,7 @@ internal fun MessageList( linkCardResolution: LinkCardResolution, canViewProfile: Boolean, onJumpConsumed: () -> Unit = {}, + topBarBottom: Dp = 0.dp, ) { val keyboard = rememberKeyboardController() val listState = rememberLazyListState() @@ -359,6 +362,7 @@ internal fun MessageList( focused = focused, animateInsertion = animateInsertion, showsSenderGutter = isGroup, + topBarBottom = topBarBottom, quickReactionStrip = if (focused && bubble?.messageId == state.selection?.messageId) { state.quickReactionStrip } else { diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt index d76bdf3fa2..05672c84bc 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt @@ -97,6 +97,8 @@ internal fun MessageRow( animateInsertion: Boolean, showsSenderGutter: Boolean, quickReactionStrip: List = emptyList(), + /** Where the top bar ends, from the window's top; the strip stays below it. */ + topBarBottom: Dp = 0.dp, attention: () -> Float = { 0f }, ) { val onAction = LocalChatActionHandler.current @@ -346,9 +348,13 @@ internal fun MessageRow( onAction(ChatAction.ClearSelection) onAction(ChatAction.OpenReactionPicker(item.messageId)) }, - // Clear of the selection bar's actions at the top. - minTop = WindowInsets.statusBars.asPaddingValues() - .calculateTopPadding() + SELECTION_BAR_CLEARANCE, + // Clear of the selection bar, measured rather than + // assumed: a guess at its height flipped the strip + // below bubbles that had room above. + minTop = maxOf( + topBarBottom, + WindowInsets.statusBars.asPaddingValues().calculateTopPadding(), + ) + STRIP_BAR_GAP, ) } } @@ -501,7 +507,7 @@ private fun senderNameInset(showsGutter: Boolean): Dp = // doesn't expose its resolved width outward, so the pill row underneath it re-derives the same // fraction of the shared row width instead. private const val BUBBLE_ROW_WIDTH_FRACTION = 0.78f -private val SELECTION_BAR_CLEARANCE = 72.dp +private val STRIP_BAR_GAP = 8.dp private val AFFORDANCE_SIZE = 32.dp private val AFFORDANCE_INSET = 20.dp From acec931c1899363b723bb0a0656397eda824e0ad Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 26 Sep 2026 07:31:39 -0400 Subject: [PATCH 54/58] test(reactions): read reactions.json from test-vectors instead of three copies --- apps/flipcash/shared/chat/build.gradle.kts | 8 + .../chat/reactions/ReactionVectorsTest.kt | 9 +- libs/emojis/build.gradle.kts | 9 + .../src/androidTest/assets/reactions.json | 1766 ----------------- .../reactions/RecentReactionsVectorTest.kt | 4 +- libs/emojis/src/test/resources/reactions.json | 1766 ----------------- test-vectors/README.md | 1 + .../resources => test-vectors}/reactions.json | 0 8 files changed, 25 insertions(+), 3538 deletions(-) delete mode 100644 libs/emojis/src/androidTest/assets/reactions.json delete mode 100644 libs/emojis/src/test/resources/reactions.json rename {apps/flipcash/shared/chat/src/test/resources => test-vectors}/reactions.json (100%) diff --git a/apps/flipcash/shared/chat/build.gradle.kts b/apps/flipcash/shared/chat/build.gradle.kts index fe2edf74dd..afa205cec9 100644 --- a/apps/flipcash/shared/chat/build.gradle.kts +++ b/apps/flipcash/shared/chat/build.gradle.kts @@ -6,6 +6,14 @@ android { namespace = "${Gradle.flipcashNamespace}.shared.chat" } +// reactions.json is read from the repo's canonical test-vectors/ rather than copied in. +androidComponents { + onVariants { variant -> + val vectors = rootProject.file("test-vectors").path + variant.hostTests.values.forEach { it.sources.resources?.addStaticSourceDirectory(vectors) } + } +} + dependencies { testImplementation(kotlin("test")) testImplementation(libs.bundles.unit.testing) diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/reactions/ReactionVectorsTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/reactions/ReactionVectorsTest.kt index 2e8ea237e2..d8ed1c156b 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/reactions/ReactionVectorsTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/reactions/ReactionVectorsTest.kt @@ -11,12 +11,13 @@ import kotlin.test.assertTrue import kotlin.time.Instant /** - * `test-vectors/reactions.json`. The canonical copy lives in the orchestrator repo (#17); this one - * is synced verbatim. A failure here is either a real regression or a cross-platform decision that + * `test-vectors/reactions.json`, read in place through this module's test resources. It is a + * verbatim copy of the orchestrator's canonical fixture (#17). A failure here is either a real regression or a cross-platform decision that * has to be made in the canonical fixture and re-synced to both platforms — never a local edit. * - * Covers the `merge`, `order` and `strip` sections; `defaults`, `recents` and `drawability` are - * covered by `libs:emojis`'s `RecentReactionsVectorTest`, next to where `RecentReactions` lives. + * Covers the `merge`, `order` and `strip` sections; `defaults` and `recents` are covered by + * `libs:emojis`'s `RecentReactionsVectorTest`, and `drawability` by its instrumented + * `EmojiDrawabilityVectorTest`. * * `merge` replays each vector's steps against a fresh [ReactionState] — the same state machine as * iOS's `FlipcashCore` `ReactionState` and `gen_reactions.py`'s reference `Model` — and checks the diff --git a/libs/emojis/build.gradle.kts b/libs/emojis/build.gradle.kts index a03124a6c2..370070ee78 100644 --- a/libs/emojis/build.gradle.kts +++ b/libs/emojis/build.gradle.kts @@ -8,6 +8,15 @@ android { namespace = "${Gradle.codeNamespace}.libs.emojis" } +// reactions.json is read from the repo's canonical test-vectors/ rather than copied in. +androidComponents { + onVariants { variant -> + val vectors = rootProject.file("test-vectors").path + variant.hostTests.values.forEach { it.sources.resources?.addStaticSourceDirectory(vectors) } + variant.deviceTests.values.forEach { it.sources.assets?.addStaticSourceDirectory(vectors) } + } +} + dependencies { implementation(libs.bundles.hilt) ksp(libs.bundles.hilt.compiler) diff --git a/libs/emojis/src/androidTest/assets/reactions.json b/libs/emojis/src/androidTest/assets/reactions.json deleted file mode 100644 index b8b86ea67d..0000000000 --- a/libs/emojis/src/androidTest/assets/reactions.json +++ /dev/null @@ -1,1766 +0,0 @@ -{ - "defaults": [ - "❤️", - "👍", - "😂", - "😮", - "😢", - "🔥" - ], - "merge": [ - { - "name": "summary-renders", - "self": "me", - "steps": [ - { - "op": "summary", - "reactions": [ - { - "emoji": "👍", - "count": 2, - "self": false, - "version": 4 - }, - { - "emoji": "🔥", - "count": 1, - "self": true, - "version": 1 - } - ] - } - ], - "expect": { - "pills": [ - { - "emoji": "👍", - "count": 2, - "selfReacted": false, - "pending": false - }, - { - "emoji": "🔥", - "count": 1, - "selfReacted": true, - "pending": false - } - ], - "calls": [], - "errors": [] - }, - "note": "A summary alone. Counts as given; self from self_reactor." - }, - { - "name": "stale-update-dropped", - "self": "me", - "steps": [ - { - "op": "summary", - "reactions": [ - { - "emoji": "👍", - "count": 3, - "self": false, - "version": 7 - } - ] - }, - { - "op": "update", - "emoji": "👍", - "actor": "ana", - "action": "REMOVED", - "count": 2, - "version": 6 - } - ], - "expect": { - "pills": [ - { - "emoji": "👍", - "count": 3, - "selfReacted": false, - "pending": false - } - ], - "calls": [], - "errors": [] - }, - "note": "Version 6 is below the held 7, so it is an update the summary already folded in." - }, - { - "name": "equal-version-dropped", - "self": "me", - "steps": [ - { - "op": "summary", - "reactions": [ - { - "emoji": "👍", - "count": 3, - "self": false, - "version": 7 - } - ] - }, - { - "op": "update", - "emoji": "👍", - "actor": "ana", - "action": "REMOVED", - "count": 2, - "version": 7 - } - ], - "expect": { - "pills": [ - { - "emoji": "👍", - "count": 3, - "selfReacted": false, - "pending": false - } - ], - "calls": [], - "errors": [] - }, - "note": "Accept strictly greater. The same version is the same state." - }, - { - "name": "newer-update-applied", - "self": "me", - "steps": [ - { - "op": "summary", - "reactions": [ - { - "emoji": "👍", - "count": 3, - "self": false, - "version": 7 - } - ] - }, - { - "op": "update", - "emoji": "👍", - "actor": "ana", - "action": "ADDED", - "count": 4, - "version": 8 - } - ], - "expect": { - "pills": [ - { - "emoji": "👍", - "count": 4, - "selfReacted": false, - "pending": false - } - ], - "calls": [], - "errors": [] - }, - "note": "Count comes from the update, not from adding one: an update is state, not a delta." - }, - { - "name": "update-to-zero-removes-pill", - "self": "me", - "steps": [ - { - "op": "summary", - "reactions": [ - { - "emoji": "👍", - "count": 1, - "self": false, - "version": 3 - } - ] - }, - { - "op": "update", - "emoji": "👍", - "actor": "ana", - "action": "REMOVED", - "count": 0, - "version": 4 - } - ], - "expect": { - "pills": [], - "calls": [], - "errors": [] - }, - "note": "Zero count, no pill." - }, - { - "name": "tombstone-rejects-late-add", - "self": "me", - "steps": [ - { - "op": "summary", - "reactions": [ - { - "emoji": "👍", - "count": 1, - "self": false, - "version": 3 - } - ] - }, - { - "op": "update", - "emoji": "👍", - "actor": "ana", - "action": "REMOVED", - "count": 0, - "version": 4 - }, - { - "op": "update", - "emoji": "👍", - "actor": "ana", - "action": "ADDED", - "count": 1, - "version": 3 - } - ], - "expect": { - "pills": [], - "calls": [], - "errors": [] - }, - "note": "The server keeps an emptied emoji's version, so the client must too. Without the tombstone the late ADDED at 3 would resurrect the pill." - }, - { - "name": "readd-after-empty", - "self": "me", - "steps": [ - { - "op": "summary", - "reactions": [ - { - "emoji": "👍", - "count": 1, - "self": false, - "version": 3 - } - ] - }, - { - "op": "update", - "emoji": "👍", - "actor": "ana", - "action": "REMOVED", - "count": 0, - "version": 4 - }, - { - "op": "update", - "emoji": "👍", - "actor": "ben", - "action": "ADDED", - "count": 1, - "version": 5 - } - ], - "expect": { - "pills": [ - { - "emoji": "👍", - "count": 1, - "selfReacted": false, - "pending": false - } - ], - "calls": [], - "errors": [] - }, - "note": "A re-add comes above the tombstone and is accepted." - }, - { - "name": "new-emoji-from-stream", - "self": "me", - "steps": [ - { - "op": "summary", - "reactions": [ - { - "emoji": "👍", - "count": 1, - "self": false, - "version": 1 - } - ] - }, - { - "op": "update", - "emoji": "🎉", - "actor": "ana", - "action": "ADDED", - "count": 1, - "version": 1 - } - ], - "expect": { - "pills": [ - { - "emoji": "🎉", - "count": 1, - "selfReacted": false, - "pending": false - }, - { - "emoji": "👍", - "count": 1, - "selfReacted": false, - "pending": false - } - ], - "calls": [], - "errors": [] - }, - "note": "An emoji we have never held has no watermark; any version is accepted. Equal counts fall back to UTF-8 byte order: 🎉 (F0 9F 8E 89) before 👍 (F0 9F 91 8D)." - }, - { - "name": "own-update-from-another-device", - "self": "me", - "steps": [ - { - "op": "summary", - "reactions": [ - { - "emoji": "👍", - "count": 1, - "self": false, - "version": 1 - } - ] - }, - { - "op": "update", - "emoji": "👍", - "actor": "me", - "action": "ADDED", - "count": 2, - "version": 2 - } - ], - "expect": { - "pills": [ - { - "emoji": "👍", - "count": 2, - "selfReacted": true, - "pending": false - } - ], - "calls": [], - "errors": [] - }, - "note": "Our own reaction made on another device arrives on the stream and highlights the pill." - }, - { - "name": "tap-adds-optimistically", - "self": "me", - "steps": [ - { - "op": "summary", - "reactions": [ - { - "emoji": "👍", - "count": 1, - "self": false, - "version": 1 - } - ] - }, - { - "op": "tap", - "emoji": "👍" - } - ], - "expect": { - "pills": [ - { - "emoji": "👍", - "count": 2, - "selfReacted": true, - "pending": true - } - ], - "calls": [ - { - "op": "add", - "emoji": "👍" - } - ], - "errors": [] - }, - "note": "The pill changes before the server answers, and is marked pending." - }, - { - "name": "tap-new-emoji", - "self": "me", - "steps": [ - { - "op": "tap", - "emoji": "🔥" - } - ], - "expect": { - "pills": [ - { - "emoji": "🔥", - "count": 1, - "selfReacted": true, - "pending": true - } - ], - "calls": [ - { - "op": "add", - "emoji": "🔥" - } - ], - "errors": [] - }, - "note": "Tapping an emoji the message does not have yet, from the strip or picker." - }, - { - "name": "tap-confirmed", - "self": "me", - "steps": [ - { - "op": "summary", - "reactions": [ - { - "emoji": "👍", - "count": 1, - "self": false, - "version": 1 - } - ] - }, - { - "op": "tap", - "emoji": "👍" - }, - { - "op": "respond", - "emoji": "👍", - "result": "OK", - "reaction": { - "count": 2, - "self": true, - "version": 2 - } - } - ], - "expect": { - "pills": [ - { - "emoji": "👍", - "count": 2, - "selfReacted": true, - "pending": false - } - ], - "calls": [ - { - "op": "add", - "emoji": "👍" - } - ], - "errors": [] - }, - "note": "The response's EmojiReaction replaces the pending state." - }, - { - "name": "tap-removes", - "self": "me", - "steps": [ - { - "op": "summary", - "reactions": [ - { - "emoji": "👍", - "count": 2, - "self": true, - "version": 5 - } - ] - }, - { - "op": "tap", - "emoji": "👍" - }, - { - "op": "respond", - "emoji": "👍", - "result": "OK", - "reaction": { - "count": 1, - "self": false, - "version": 6 - } - } - ], - "expect": { - "pills": [ - { - "emoji": "👍", - "count": 1, - "selfReacted": false, - "pending": false - } - ], - "calls": [ - { - "op": "remove", - "emoji": "👍" - } - ], - "errors": [] - }, - "note": "Tapping a highlighted pill removes." - }, - { - "name": "remove-last-reaction", - "self": "me", - "steps": [ - { - "op": "summary", - "reactions": [ - { - "emoji": "👍", - "count": 1, - "self": true, - "version": 5 - } - ] - }, - { - "op": "tap", - "emoji": "👍" - } - ], - "expect": { - "pills": [], - "calls": [ - { - "op": "remove", - "emoji": "👍" - } - ], - "errors": [] - }, - "note": "Removing the only reaction hides the pill immediately, before the response." - }, - { - "name": "stream-during-pending-keeps-highlight", - "self": "me", - "steps": [ - { - "op": "summary", - "reactions": [ - { - "emoji": "👍", - "count": 1, - "self": false, - "version": 1 - } - ] - }, - { - "op": "tap", - "emoji": "👍" - }, - { - "op": "update", - "emoji": "👍", - "actor": "ana", - "action": "ADDED", - "count": 2, - "version": 2 - } - ], - "expect": { - "pills": [ - { - "emoji": "👍", - "count": 3, - "selfReacted": true, - "pending": true - } - ], - "calls": [ - { - "op": "add", - "emoji": "👍" - } - ], - "errors": [] - }, - "note": "Someone else's reaction lands while ours is on the wire: their count shows, our highlight stays." - }, - { - "name": "own-echo-before-response", - "self": "me", - "steps": [ - { - "op": "summary", - "reactions": [ - { - "emoji": "👍", - "count": 1, - "self": false, - "version": 1 - } - ] - }, - { - "op": "tap", - "emoji": "👍" - }, - { - "op": "update", - "emoji": "👍", - "actor": "me", - "action": "ADDED", - "count": 2, - "version": 2 - }, - { - "op": "respond", - "emoji": "👍", - "result": "OK", - "reaction": { - "count": 2, - "self": true, - "version": 2 - } - } - ], - "expect": { - "pills": [ - { - "emoji": "👍", - "count": 2, - "selfReacted": true, - "pending": false - } - ], - "calls": [ - { - "op": "add", - "emoji": "👍" - } - ], - "errors": [] - }, - "note": "The stream can deliver our own add before the RPC returns. It must not count us twice (3), and the response at the same version is then a no-op." - }, - { - "name": "coalesce-on-off-on", - "self": "me", - "steps": [ - { - "op": "summary", - "reactions": [ - { - "emoji": "👍", - "count": 1, - "self": false, - "version": 1 - } - ] - }, - { - "op": "tap", - "emoji": "👍" - }, - { - "op": "tap", - "emoji": "👍" - }, - { - "op": "tap", - "emoji": "👍" - } - ], - "expect": { - "pills": [ - { - "emoji": "👍", - "count": 2, - "selfReacted": true, - "pending": true - } - ], - "calls": [ - { - "op": "add", - "emoji": "👍" - } - ], - "errors": [] - }, - "note": "Three taps while the first add is in flight: one call. The later taps only move the displayed state." - }, - { - "name": "coalesce-on-off-then-settle", - "self": "me", - "steps": [ - { - "op": "summary", - "reactions": [ - { - "emoji": "👍", - "count": 1, - "self": false, - "version": 1 - } - ] - }, - { - "op": "tap", - "emoji": "👍" - }, - { - "op": "tap", - "emoji": "👍" - }, - { - "op": "respond", - "emoji": "👍", - "result": "OK", - "reaction": { - "count": 2, - "self": true, - "version": 2 - } - } - ], - "expect": { - "pills": [ - { - "emoji": "👍", - "count": 1, - "selfReacted": false, - "pending": true - } - ], - "calls": [ - { - "op": "add", - "emoji": "👍" - }, - { - "op": "remove", - "emoji": "👍" - } - ], - "errors": [] - }, - "note": "On, then off, while the add is in flight. The add lands; the final state (off) differs from the server's (on), so a remove follows, and the pill shows the off state as pending until it returns." - }, - { - "name": "coalesce-on-off-on-settle", - "self": "me", - "steps": [ - { - "op": "summary", - "reactions": [ - { - "emoji": "👍", - "count": 1, - "self": false, - "version": 1 - } - ] - }, - { - "op": "tap", - "emoji": "👍" - }, - { - "op": "tap", - "emoji": "👍" - }, - { - "op": "tap", - "emoji": "👍" - }, - { - "op": "respond", - "emoji": "👍", - "result": "OK", - "reaction": { - "count": 2, - "self": true, - "version": 2 - } - } - ], - "expect": { - "pills": [ - { - "emoji": "👍", - "count": 2, - "selfReacted": true, - "pending": false - } - ], - "calls": [ - { - "op": "add", - "emoji": "👍" - } - ], - "errors": [] - }, - "note": "On, off, on. The add lands and the final state already matches, so nothing more is sent." - }, - { - "name": "network-failure-rolls-back", - "self": "me", - "steps": [ - { - "op": "summary", - "reactions": [ - { - "emoji": "👍", - "count": 1, - "self": false, - "version": 1 - } - ] - }, - { - "op": "tap", - "emoji": "👍" - }, - { - "op": "respond", - "emoji": "👍", - "result": "NETWORK" - } - ], - "expect": { - "pills": [ - { - "emoji": "👍", - "count": 1, - "selfReacted": false, - "pending": false - } - ], - "calls": [ - { - "op": "add", - "emoji": "👍" - } - ], - "errors": [ - "reactionFailed" - ] - }, - "note": "Back to the confirmed state, with the generic error." - }, - { - "name": "denied-rolls-back", - "self": "me", - "steps": [ - { - "op": "tap", - "emoji": "🫩" - }, - { - "op": "respond", - "emoji": "🫩", - "result": "DENIED" - } - ], - "expect": { - "pills": [], - "calls": [ - { - "op": "add", - "emoji": "🫩" - } - ], - "errors": [ - "reactionFailed" - ] - }, - "note": "DENIED is shown like a network failure; the catalog should make it rare." - }, - { - "name": "too-many-types", - "self": "me", - "steps": [ - { - "op": "tap", - "emoji": "🦖" - }, - { - "op": "respond", - "emoji": "🦖", - "result": "TOO_MANY_REACTION_TYPES" - } - ], - "expect": { - "pills": [], - "calls": [ - { - "op": "add", - "emoji": "🦖" - } - ], - "errors": [ - "tooManyReactionTypes" - ] - }, - "note": "The 21st distinct emoji on a message." - }, - { - "name": "message-not-found-is-silent", - "self": "me", - "steps": [ - { - "op": "summary", - "reactions": [ - { - "emoji": "👍", - "count": 1, - "self": false, - "version": 1 - } - ] - }, - { - "op": "tap", - "emoji": "👍" - }, - { - "op": "respond", - "emoji": "👍", - "result": "MESSAGE_NOT_FOUND" - } - ], - "expect": { - "pills": [ - { - "emoji": "👍", - "count": 1, - "selfReacted": false, - "pending": false - } - ], - "calls": [ - { - "op": "add", - "emoji": "👍" - } - ], - "errors": [] - }, - "note": "The message was deleted; the stream is removing it, so no error is shown." - }, - { - "name": "cannot-react-is-silent", - "self": "me", - "steps": [ - { - "op": "tap", - "emoji": "👍" - }, - { - "op": "respond", - "emoji": "👍", - "result": "CANNOT_REACT" - } - ], - "expect": { - "pills": [], - "calls": [ - { - "op": "add", - "emoji": "👍" - } - ], - "errors": [] - }, - "note": "The strip should not have been offered. Roll back quietly." - }, - { - "name": "failure-drops-coalesced-taps", - "self": "me", - "steps": [ - { - "op": "summary", - "reactions": [ - { - "emoji": "👍", - "count": 1, - "self": false, - "version": 1 - } - ] - }, - { - "op": "tap", - "emoji": "👍" - }, - { - "op": "tap", - "emoji": "👍" - }, - { - "op": "tap", - "emoji": "👍" - }, - { - "op": "respond", - "emoji": "👍", - "result": "NETWORK" - } - ], - "expect": { - "pills": [ - { - "emoji": "👍", - "count": 1, - "selfReacted": false, - "pending": false - } - ], - "calls": [ - { - "op": "add", - "emoji": "👍" - } - ], - "errors": [ - "reactionFailed" - ] - }, - "note": "Nothing is queued: a failure discards the taps made while it was in flight too." - }, - { - "name": "summary-omits-emptied-emoji", - "self": "me", - "steps": [ - { - "op": "summary", - "reactions": [ - { - "emoji": "👍", - "count": 1, - "self": false, - "version": 1 - }, - { - "emoji": "🔥", - "count": 2, - "self": false, - "version": 3 - } - ] - }, - { - "op": "summary", - "reactions": [ - { - "emoji": "🔥", - "count": 2, - "self": false, - "version": 3 - } - ] - } - ], - "expect": { - "pills": [ - { - "emoji": "🔥", - "count": 2, - "selfReacted": false, - "pending": false - } - ], - "calls": [], - "errors": [] - }, - "note": "A later summary without 👍 means it emptied." - }, - { - "name": "summary-keeps-pending-emoji", - "self": "me", - "steps": [ - { - "op": "tap", - "emoji": "👍" - }, - { - "op": "summary", - "reactions": [ - { - "emoji": "🔥", - "count": 1, - "self": false, - "version": 1 - } - ] - } - ], - "expect": { - "pills": [ - { - "emoji": "👍", - "count": 1, - "selfReacted": true, - "pending": true - }, - { - "emoji": "🔥", - "count": 1, - "selfReacted": false, - "pending": false - } - ], - "calls": [ - { - "op": "add", - "emoji": "👍" - } - ], - "errors": [] - }, - "note": "A summary fetched before our add landed must not wipe the pending tap." - }, - { - "name": "reactions-are-per-emoji", - "self": "me", - "steps": [ - { - "op": "summary", - "reactions": [ - { - "emoji": "👍", - "count": 1, - "self": false, - "version": 9 - }, - { - "emoji": "🔥", - "count": 1, - "self": false, - "version": 2 - } - ] - }, - { - "op": "update", - "emoji": "🔥", - "actor": "ana", - "action": "ADDED", - "count": 2, - "version": 3 - } - ], - "expect": { - "pills": [ - { - "emoji": "🔥", - "count": 2, - "selfReacted": false, - "pending": false - }, - { - "emoji": "👍", - "count": 1, - "selfReacted": false, - "pending": false - } - ], - "calls": [], - "errors": [] - }, - "note": "Versions are per (message, emoji). 🔥 at 3 is fresh even though 👍 is at 9." - } - ], - "order": [ - { - "name": "count-descending", - "pills": [ - { - "emoji": "👍", - "count": 1, - "boostTotal": 0 - }, - { - "emoji": "🔥", - "count": 3, - "boostTotal": 0 - }, - { - "emoji": "😂", - "count": 2, - "boostTotal": 0 - } - ], - "expect": [ - "🔥", - "😂", - "👍" - ], - "note": "Phase 1: every boost total is 0, so count decides." - }, - { - "name": "boost-before-count", - "pills": [ - { - "emoji": "👍", - "count": 5, - "boostTotal": 0 - }, - { - "emoji": "💯", - "count": 2, - "boostTotal": 1000 - }, - { - "emoji": "👎", - "count": 1, - "boostTotal": 500 - } - ], - "expect": [ - "💯", - "👎", - "👍" - ], - "note": "Phase 2: any boost outranks any count. The unit is opaque here, only its order matters." - }, - { - "name": "tie-on-utf8-bytes", - "pills": [ - { - "emoji": "😂", - "count": 1, - "boostTotal": 0 - }, - { - "emoji": "👍", - "count": 1, - "boostTotal": 0 - }, - { - "emoji": "❤️", - "count": 1, - "boostTotal": 0 - } - ], - "expect": [ - "❤️", - "👍", - "😂" - ], - "note": "Ties fall back to the server's order, UTF-8 byte order. ❤️ starts E2, the others F0." - }, - { - "name": "tie-utf8-not-utf16", - "pills": [ - { - "emoji": "☝🏻", - "count": 1, - "boostTotal": 0 - }, - { - "emoji": "☝️", - "count": 1, - "boostTotal": 0 - } - ], - "expect": [ - "☝️", - "☝🏻" - ], - "note": "The case that separates byte order from a platform's default string compare. Both start U+261D. Next, ☝️ has U+FE0F (UTF-8 EF B8 8F) and ☝🏻 has U+1F3FB (F0 9F 8F BB): EF < F0 in UTF-8, but in UTF-16 the surrogate D83C < FE0F, which would flip them." - } - ], - "strip": [ - { - "name": "no-reactions", - "recents": [ - "😂", - "❤️", - "🔥", - "👍", - "😭", - "✨" - ], - "selfReactions": [], - "expect": [ - { - "emoji": "😂", - "highlighted": false - }, - { - "emoji": "❤️", - "highlighted": false - }, - { - "emoji": "🔥", - "highlighted": false - }, - { - "emoji": "👍", - "highlighted": false - }, - { - "emoji": "😭", - "highlighted": false - }, - { - "emoji": "✨", - "highlighted": false - } - ], - "note": "Recents, in rank order." - }, - { - "name": "reacted-with-a-recent", - "recents": [ - "😂", - "❤️", - "🔥", - "👍", - "😭", - "✨" - ], - "selfReactions": [ - { - "emoji": "🔥", - "reactedAt": 100 - } - ], - "expect": [ - { - "emoji": "😂", - "highlighted": false - }, - { - "emoji": "❤️", - "highlighted": false - }, - { - "emoji": "🔥", - "highlighted": true - }, - { - "emoji": "👍", - "highlighted": false - }, - { - "emoji": "😭", - "highlighted": false - }, - { - "emoji": "✨", - "highlighted": false - } - ], - "note": "A reaction already in the recents is highlighted in place, not moved." - }, - { - "name": "reacted-outside-recents", - "recents": [ - "😂", - "❤️", - "🔥", - "👍", - "😭", - "✨" - ], - "selfReactions": [ - { - "emoji": "💥", - "reactedAt": 100 - } - ], - "expect": [ - { - "emoji": "😂", - "highlighted": false - }, - { - "emoji": "❤️", - "highlighted": false - }, - { - "emoji": "🔥", - "highlighted": false - }, - { - "emoji": "👍", - "highlighted": false - }, - { - "emoji": "😭", - "highlighted": false - }, - { - "emoji": "✨", - "highlighted": false - }, - { - "emoji": "💥", - "highlighted": true - } - ], - "note": "After the six, next to +. The WhatsApp screenshot in the design review." - }, - { - "name": "several-outside-newest-first", - "recents": [ - "😂", - "❤️", - "🔥", - "👍", - "😭", - "✨" - ], - "selfReactions": [ - { - "emoji": "💥", - "reactedAt": 100 - }, - { - "emoji": "🦖", - "reactedAt": 300 - }, - { - "emoji": "🫠", - "reactedAt": 200 - } - ], - "expect": [ - { - "emoji": "😂", - "highlighted": false - }, - { - "emoji": "❤️", - "highlighted": false - }, - { - "emoji": "🔥", - "highlighted": false - }, - { - "emoji": "👍", - "highlighted": false - }, - { - "emoji": "😭", - "highlighted": false - }, - { - "emoji": "✨", - "highlighted": false - }, - { - "emoji": "🦖", - "highlighted": true - }, - { - "emoji": "🫠", - "highlighted": true - }, - { - "emoji": "💥", - "highlighted": true - } - ], - "note": "Newest reaction first, so the one you just made is the one visible before scrolling." - }, - { - "name": "mixed", - "recents": [ - "😂", - "❤️", - "🔥", - "👍", - "😭", - "✨" - ], - "selfReactions": [ - { - "emoji": "❤️", - "reactedAt": 50 - }, - { - "emoji": "🦖", - "reactedAt": 60 - } - ], - "expect": [ - { - "emoji": "😂", - "highlighted": false - }, - { - "emoji": "❤️", - "highlighted": true - }, - { - "emoji": "🔥", - "highlighted": false - }, - { - "emoji": "👍", - "highlighted": false - }, - { - "emoji": "😭", - "highlighted": false - }, - { - "emoji": "✨", - "highlighted": false - }, - { - "emoji": "🦖", - "highlighted": true - } - ], - "note": "Only the reaction outside the recents is appended." - } - ], - "recents": [ - { - "name": "empty-pads-defaults", - "uses": [], - "undrawable": [], - "limit": 6, - "expect": [ - "❤️", - "👍", - "😂", - "😮", - "😢", - "🔥" - ], - "note": "Nothing used yet: the defaults from node 10286:2573, in their order." - }, - { - "name": "frequency-first", - "uses": [ - { - "emoji": "🦖", - "usedAt": 1 - }, - { - "emoji": "🦖", - "usedAt": 2 - }, - { - "emoji": "🦖", - "usedAt": 3 - }, - { - "emoji": "🫠", - "usedAt": 4 - } - ], - "undrawable": [], - "limit": 6, - "expect": [ - "🦖", - "🫠", - "❤️", - "👍", - "😂", - "😮" - ], - "note": "Most used first, then padding with the defaults." - }, - { - "name": "recency-breaks-ties", - "uses": [ - { - "emoji": "🦖", - "usedAt": 1 - }, - { - "emoji": "🫠", - "usedAt": 2 - }, - { - "emoji": "🎉", - "usedAt": 3 - } - ], - "undrawable": [], - "limit": 6, - "expect": [ - "🎉", - "🫠", - "🦖", - "❤️", - "👍", - "😂" - ], - "note": "Equal counts: most recently used first." - }, - { - "name": "padding-skips-used-defaults", - "uses": [ - { - "emoji": "👍", - "usedAt": 1 - }, - { - "emoji": "👍", - "usedAt": 2 - } - ], - "undrawable": [], - "limit": 6, - "expect": [ - "👍", - "❤️", - "😂", - "😮", - "😢", - "🔥" - ], - "note": "A default already ranked is not repeated." - }, - { - "name": "undrawable-skipped", - "uses": [ - { - "emoji": "🫩", - "usedAt": 1 - }, - { - "emoji": "🫩", - "usedAt": 2 - }, - { - "emoji": "🦖", - "usedAt": 3 - } - ], - "undrawable": [ - "🫩" - ], - "limit": 6, - "expect": [ - "🦖", - "❤️", - "👍", - "😂", - "😮", - "😢" - ], - "note": "Emoji this device cannot draw are not offered, even if used on another OS version." - }, - { - "name": "picker-row-of-seven", - "uses": [], - "undrawable": [], - "limit": 7, - "expect": [ - "❤️", - "👍", - "😂", - "😮", - "😢", - "🔥" - ], - "note": "The picker asks for 7 and there are 6 defaults, so an unused picker shows 6. Not padded further: the row is not worth an invented seventh default." - }, - { - "name": "limit-truncates", - "uses": [ - { - "emoji": "🦖", - "usedAt": 0 - }, - { - "emoji": "🫠", - "usedAt": 1 - }, - { - "emoji": "🎉", - "usedAt": 2 - }, - { - "emoji": "💥", - "usedAt": 3 - }, - { - "emoji": "✨", - "usedAt": 4 - }, - { - "emoji": "😭", - "usedAt": 5 - }, - { - "emoji": "🥺", - "usedAt": 6 - }, - { - "emoji": "🙏", - "usedAt": 7 - } - ], - "undrawable": [], - "limit": 6, - "expect": [ - "🙏", - "🥺", - "😭", - "✨", - "💥", - "🎉" - ], - "note": "All used once, so recency orders them and the oldest two drop." - } - ], - "drawability": [ - { - "emoji": "😀", - "drawable": true, - "note": "Emoji 1.0, single code point." - }, - { - "emoji": "👍🏽", - "drawable": true, - "note": "Base plus skin-tone modifier." - }, - { - "emoji": "🧑🏻‍🤝‍🧑🏿", - "drawable": true, - "note": "Mixed-skin-tone ZWJ sequence, Emoji 12.1. The case a glyph-count rule gets wrong on iOS." - }, - { - "emoji": "🐈‍🦄", - "drawable": false, - "note": "Not an RGI sequence, so no font has a ligature; it draws as two emoji." - }, - { - "emoji": "🇦🇦", - "drawable": false, - "note": "Regional indicators that are not a flag draw as two letters." - } - ] -} diff --git a/libs/emojis/src/test/kotlin/com/getcode/libs/emojis/reactions/RecentReactionsVectorTest.kt b/libs/emojis/src/test/kotlin/com/getcode/libs/emojis/reactions/RecentReactionsVectorTest.kt index 544167f4f2..c219156b13 100644 --- a/libs/emojis/src/test/kotlin/com/getcode/libs/emojis/reactions/RecentReactionsVectorTest.kt +++ b/libs/emojis/src/test/kotlin/com/getcode/libs/emojis/reactions/RecentReactionsVectorTest.kt @@ -11,8 +11,8 @@ import kotlin.test.assertTrue import kotlin.time.Instant /** - * `test-vectors/reactions.json`. The canonical copy lives in the orchestrator repo (#17); this one - * is synced verbatim. A failure here is either a real regression or a cross-platform decision that + * `test-vectors/reactions.json`, read in place through this module's test resources. It is a + * verbatim copy of the orchestrator's canonical fixture (#17). A failure here is either a real regression or a cross-platform decision that * has to be made in the canonical fixture and re-synced to both platforms — never a local edit. * * Covers `defaults` and `recents`; `merge`, `order` and `strip` are covered by diff --git a/libs/emojis/src/test/resources/reactions.json b/libs/emojis/src/test/resources/reactions.json deleted file mode 100644 index b8b86ea67d..0000000000 --- a/libs/emojis/src/test/resources/reactions.json +++ /dev/null @@ -1,1766 +0,0 @@ -{ - "defaults": [ - "❤️", - "👍", - "😂", - "😮", - "😢", - "🔥" - ], - "merge": [ - { - "name": "summary-renders", - "self": "me", - "steps": [ - { - "op": "summary", - "reactions": [ - { - "emoji": "👍", - "count": 2, - "self": false, - "version": 4 - }, - { - "emoji": "🔥", - "count": 1, - "self": true, - "version": 1 - } - ] - } - ], - "expect": { - "pills": [ - { - "emoji": "👍", - "count": 2, - "selfReacted": false, - "pending": false - }, - { - "emoji": "🔥", - "count": 1, - "selfReacted": true, - "pending": false - } - ], - "calls": [], - "errors": [] - }, - "note": "A summary alone. Counts as given; self from self_reactor." - }, - { - "name": "stale-update-dropped", - "self": "me", - "steps": [ - { - "op": "summary", - "reactions": [ - { - "emoji": "👍", - "count": 3, - "self": false, - "version": 7 - } - ] - }, - { - "op": "update", - "emoji": "👍", - "actor": "ana", - "action": "REMOVED", - "count": 2, - "version": 6 - } - ], - "expect": { - "pills": [ - { - "emoji": "👍", - "count": 3, - "selfReacted": false, - "pending": false - } - ], - "calls": [], - "errors": [] - }, - "note": "Version 6 is below the held 7, so it is an update the summary already folded in." - }, - { - "name": "equal-version-dropped", - "self": "me", - "steps": [ - { - "op": "summary", - "reactions": [ - { - "emoji": "👍", - "count": 3, - "self": false, - "version": 7 - } - ] - }, - { - "op": "update", - "emoji": "👍", - "actor": "ana", - "action": "REMOVED", - "count": 2, - "version": 7 - } - ], - "expect": { - "pills": [ - { - "emoji": "👍", - "count": 3, - "selfReacted": false, - "pending": false - } - ], - "calls": [], - "errors": [] - }, - "note": "Accept strictly greater. The same version is the same state." - }, - { - "name": "newer-update-applied", - "self": "me", - "steps": [ - { - "op": "summary", - "reactions": [ - { - "emoji": "👍", - "count": 3, - "self": false, - "version": 7 - } - ] - }, - { - "op": "update", - "emoji": "👍", - "actor": "ana", - "action": "ADDED", - "count": 4, - "version": 8 - } - ], - "expect": { - "pills": [ - { - "emoji": "👍", - "count": 4, - "selfReacted": false, - "pending": false - } - ], - "calls": [], - "errors": [] - }, - "note": "Count comes from the update, not from adding one: an update is state, not a delta." - }, - { - "name": "update-to-zero-removes-pill", - "self": "me", - "steps": [ - { - "op": "summary", - "reactions": [ - { - "emoji": "👍", - "count": 1, - "self": false, - "version": 3 - } - ] - }, - { - "op": "update", - "emoji": "👍", - "actor": "ana", - "action": "REMOVED", - "count": 0, - "version": 4 - } - ], - "expect": { - "pills": [], - "calls": [], - "errors": [] - }, - "note": "Zero count, no pill." - }, - { - "name": "tombstone-rejects-late-add", - "self": "me", - "steps": [ - { - "op": "summary", - "reactions": [ - { - "emoji": "👍", - "count": 1, - "self": false, - "version": 3 - } - ] - }, - { - "op": "update", - "emoji": "👍", - "actor": "ana", - "action": "REMOVED", - "count": 0, - "version": 4 - }, - { - "op": "update", - "emoji": "👍", - "actor": "ana", - "action": "ADDED", - "count": 1, - "version": 3 - } - ], - "expect": { - "pills": [], - "calls": [], - "errors": [] - }, - "note": "The server keeps an emptied emoji's version, so the client must too. Without the tombstone the late ADDED at 3 would resurrect the pill." - }, - { - "name": "readd-after-empty", - "self": "me", - "steps": [ - { - "op": "summary", - "reactions": [ - { - "emoji": "👍", - "count": 1, - "self": false, - "version": 3 - } - ] - }, - { - "op": "update", - "emoji": "👍", - "actor": "ana", - "action": "REMOVED", - "count": 0, - "version": 4 - }, - { - "op": "update", - "emoji": "👍", - "actor": "ben", - "action": "ADDED", - "count": 1, - "version": 5 - } - ], - "expect": { - "pills": [ - { - "emoji": "👍", - "count": 1, - "selfReacted": false, - "pending": false - } - ], - "calls": [], - "errors": [] - }, - "note": "A re-add comes above the tombstone and is accepted." - }, - { - "name": "new-emoji-from-stream", - "self": "me", - "steps": [ - { - "op": "summary", - "reactions": [ - { - "emoji": "👍", - "count": 1, - "self": false, - "version": 1 - } - ] - }, - { - "op": "update", - "emoji": "🎉", - "actor": "ana", - "action": "ADDED", - "count": 1, - "version": 1 - } - ], - "expect": { - "pills": [ - { - "emoji": "🎉", - "count": 1, - "selfReacted": false, - "pending": false - }, - { - "emoji": "👍", - "count": 1, - "selfReacted": false, - "pending": false - } - ], - "calls": [], - "errors": [] - }, - "note": "An emoji we have never held has no watermark; any version is accepted. Equal counts fall back to UTF-8 byte order: 🎉 (F0 9F 8E 89) before 👍 (F0 9F 91 8D)." - }, - { - "name": "own-update-from-another-device", - "self": "me", - "steps": [ - { - "op": "summary", - "reactions": [ - { - "emoji": "👍", - "count": 1, - "self": false, - "version": 1 - } - ] - }, - { - "op": "update", - "emoji": "👍", - "actor": "me", - "action": "ADDED", - "count": 2, - "version": 2 - } - ], - "expect": { - "pills": [ - { - "emoji": "👍", - "count": 2, - "selfReacted": true, - "pending": false - } - ], - "calls": [], - "errors": [] - }, - "note": "Our own reaction made on another device arrives on the stream and highlights the pill." - }, - { - "name": "tap-adds-optimistically", - "self": "me", - "steps": [ - { - "op": "summary", - "reactions": [ - { - "emoji": "👍", - "count": 1, - "self": false, - "version": 1 - } - ] - }, - { - "op": "tap", - "emoji": "👍" - } - ], - "expect": { - "pills": [ - { - "emoji": "👍", - "count": 2, - "selfReacted": true, - "pending": true - } - ], - "calls": [ - { - "op": "add", - "emoji": "👍" - } - ], - "errors": [] - }, - "note": "The pill changes before the server answers, and is marked pending." - }, - { - "name": "tap-new-emoji", - "self": "me", - "steps": [ - { - "op": "tap", - "emoji": "🔥" - } - ], - "expect": { - "pills": [ - { - "emoji": "🔥", - "count": 1, - "selfReacted": true, - "pending": true - } - ], - "calls": [ - { - "op": "add", - "emoji": "🔥" - } - ], - "errors": [] - }, - "note": "Tapping an emoji the message does not have yet, from the strip or picker." - }, - { - "name": "tap-confirmed", - "self": "me", - "steps": [ - { - "op": "summary", - "reactions": [ - { - "emoji": "👍", - "count": 1, - "self": false, - "version": 1 - } - ] - }, - { - "op": "tap", - "emoji": "👍" - }, - { - "op": "respond", - "emoji": "👍", - "result": "OK", - "reaction": { - "count": 2, - "self": true, - "version": 2 - } - } - ], - "expect": { - "pills": [ - { - "emoji": "👍", - "count": 2, - "selfReacted": true, - "pending": false - } - ], - "calls": [ - { - "op": "add", - "emoji": "👍" - } - ], - "errors": [] - }, - "note": "The response's EmojiReaction replaces the pending state." - }, - { - "name": "tap-removes", - "self": "me", - "steps": [ - { - "op": "summary", - "reactions": [ - { - "emoji": "👍", - "count": 2, - "self": true, - "version": 5 - } - ] - }, - { - "op": "tap", - "emoji": "👍" - }, - { - "op": "respond", - "emoji": "👍", - "result": "OK", - "reaction": { - "count": 1, - "self": false, - "version": 6 - } - } - ], - "expect": { - "pills": [ - { - "emoji": "👍", - "count": 1, - "selfReacted": false, - "pending": false - } - ], - "calls": [ - { - "op": "remove", - "emoji": "👍" - } - ], - "errors": [] - }, - "note": "Tapping a highlighted pill removes." - }, - { - "name": "remove-last-reaction", - "self": "me", - "steps": [ - { - "op": "summary", - "reactions": [ - { - "emoji": "👍", - "count": 1, - "self": true, - "version": 5 - } - ] - }, - { - "op": "tap", - "emoji": "👍" - } - ], - "expect": { - "pills": [], - "calls": [ - { - "op": "remove", - "emoji": "👍" - } - ], - "errors": [] - }, - "note": "Removing the only reaction hides the pill immediately, before the response." - }, - { - "name": "stream-during-pending-keeps-highlight", - "self": "me", - "steps": [ - { - "op": "summary", - "reactions": [ - { - "emoji": "👍", - "count": 1, - "self": false, - "version": 1 - } - ] - }, - { - "op": "tap", - "emoji": "👍" - }, - { - "op": "update", - "emoji": "👍", - "actor": "ana", - "action": "ADDED", - "count": 2, - "version": 2 - } - ], - "expect": { - "pills": [ - { - "emoji": "👍", - "count": 3, - "selfReacted": true, - "pending": true - } - ], - "calls": [ - { - "op": "add", - "emoji": "👍" - } - ], - "errors": [] - }, - "note": "Someone else's reaction lands while ours is on the wire: their count shows, our highlight stays." - }, - { - "name": "own-echo-before-response", - "self": "me", - "steps": [ - { - "op": "summary", - "reactions": [ - { - "emoji": "👍", - "count": 1, - "self": false, - "version": 1 - } - ] - }, - { - "op": "tap", - "emoji": "👍" - }, - { - "op": "update", - "emoji": "👍", - "actor": "me", - "action": "ADDED", - "count": 2, - "version": 2 - }, - { - "op": "respond", - "emoji": "👍", - "result": "OK", - "reaction": { - "count": 2, - "self": true, - "version": 2 - } - } - ], - "expect": { - "pills": [ - { - "emoji": "👍", - "count": 2, - "selfReacted": true, - "pending": false - } - ], - "calls": [ - { - "op": "add", - "emoji": "👍" - } - ], - "errors": [] - }, - "note": "The stream can deliver our own add before the RPC returns. It must not count us twice (3), and the response at the same version is then a no-op." - }, - { - "name": "coalesce-on-off-on", - "self": "me", - "steps": [ - { - "op": "summary", - "reactions": [ - { - "emoji": "👍", - "count": 1, - "self": false, - "version": 1 - } - ] - }, - { - "op": "tap", - "emoji": "👍" - }, - { - "op": "tap", - "emoji": "👍" - }, - { - "op": "tap", - "emoji": "👍" - } - ], - "expect": { - "pills": [ - { - "emoji": "👍", - "count": 2, - "selfReacted": true, - "pending": true - } - ], - "calls": [ - { - "op": "add", - "emoji": "👍" - } - ], - "errors": [] - }, - "note": "Three taps while the first add is in flight: one call. The later taps only move the displayed state." - }, - { - "name": "coalesce-on-off-then-settle", - "self": "me", - "steps": [ - { - "op": "summary", - "reactions": [ - { - "emoji": "👍", - "count": 1, - "self": false, - "version": 1 - } - ] - }, - { - "op": "tap", - "emoji": "👍" - }, - { - "op": "tap", - "emoji": "👍" - }, - { - "op": "respond", - "emoji": "👍", - "result": "OK", - "reaction": { - "count": 2, - "self": true, - "version": 2 - } - } - ], - "expect": { - "pills": [ - { - "emoji": "👍", - "count": 1, - "selfReacted": false, - "pending": true - } - ], - "calls": [ - { - "op": "add", - "emoji": "👍" - }, - { - "op": "remove", - "emoji": "👍" - } - ], - "errors": [] - }, - "note": "On, then off, while the add is in flight. The add lands; the final state (off) differs from the server's (on), so a remove follows, and the pill shows the off state as pending until it returns." - }, - { - "name": "coalesce-on-off-on-settle", - "self": "me", - "steps": [ - { - "op": "summary", - "reactions": [ - { - "emoji": "👍", - "count": 1, - "self": false, - "version": 1 - } - ] - }, - { - "op": "tap", - "emoji": "👍" - }, - { - "op": "tap", - "emoji": "👍" - }, - { - "op": "tap", - "emoji": "👍" - }, - { - "op": "respond", - "emoji": "👍", - "result": "OK", - "reaction": { - "count": 2, - "self": true, - "version": 2 - } - } - ], - "expect": { - "pills": [ - { - "emoji": "👍", - "count": 2, - "selfReacted": true, - "pending": false - } - ], - "calls": [ - { - "op": "add", - "emoji": "👍" - } - ], - "errors": [] - }, - "note": "On, off, on. The add lands and the final state already matches, so nothing more is sent." - }, - { - "name": "network-failure-rolls-back", - "self": "me", - "steps": [ - { - "op": "summary", - "reactions": [ - { - "emoji": "👍", - "count": 1, - "self": false, - "version": 1 - } - ] - }, - { - "op": "tap", - "emoji": "👍" - }, - { - "op": "respond", - "emoji": "👍", - "result": "NETWORK" - } - ], - "expect": { - "pills": [ - { - "emoji": "👍", - "count": 1, - "selfReacted": false, - "pending": false - } - ], - "calls": [ - { - "op": "add", - "emoji": "👍" - } - ], - "errors": [ - "reactionFailed" - ] - }, - "note": "Back to the confirmed state, with the generic error." - }, - { - "name": "denied-rolls-back", - "self": "me", - "steps": [ - { - "op": "tap", - "emoji": "🫩" - }, - { - "op": "respond", - "emoji": "🫩", - "result": "DENIED" - } - ], - "expect": { - "pills": [], - "calls": [ - { - "op": "add", - "emoji": "🫩" - } - ], - "errors": [ - "reactionFailed" - ] - }, - "note": "DENIED is shown like a network failure; the catalog should make it rare." - }, - { - "name": "too-many-types", - "self": "me", - "steps": [ - { - "op": "tap", - "emoji": "🦖" - }, - { - "op": "respond", - "emoji": "🦖", - "result": "TOO_MANY_REACTION_TYPES" - } - ], - "expect": { - "pills": [], - "calls": [ - { - "op": "add", - "emoji": "🦖" - } - ], - "errors": [ - "tooManyReactionTypes" - ] - }, - "note": "The 21st distinct emoji on a message." - }, - { - "name": "message-not-found-is-silent", - "self": "me", - "steps": [ - { - "op": "summary", - "reactions": [ - { - "emoji": "👍", - "count": 1, - "self": false, - "version": 1 - } - ] - }, - { - "op": "tap", - "emoji": "👍" - }, - { - "op": "respond", - "emoji": "👍", - "result": "MESSAGE_NOT_FOUND" - } - ], - "expect": { - "pills": [ - { - "emoji": "👍", - "count": 1, - "selfReacted": false, - "pending": false - } - ], - "calls": [ - { - "op": "add", - "emoji": "👍" - } - ], - "errors": [] - }, - "note": "The message was deleted; the stream is removing it, so no error is shown." - }, - { - "name": "cannot-react-is-silent", - "self": "me", - "steps": [ - { - "op": "tap", - "emoji": "👍" - }, - { - "op": "respond", - "emoji": "👍", - "result": "CANNOT_REACT" - } - ], - "expect": { - "pills": [], - "calls": [ - { - "op": "add", - "emoji": "👍" - } - ], - "errors": [] - }, - "note": "The strip should not have been offered. Roll back quietly." - }, - { - "name": "failure-drops-coalesced-taps", - "self": "me", - "steps": [ - { - "op": "summary", - "reactions": [ - { - "emoji": "👍", - "count": 1, - "self": false, - "version": 1 - } - ] - }, - { - "op": "tap", - "emoji": "👍" - }, - { - "op": "tap", - "emoji": "👍" - }, - { - "op": "tap", - "emoji": "👍" - }, - { - "op": "respond", - "emoji": "👍", - "result": "NETWORK" - } - ], - "expect": { - "pills": [ - { - "emoji": "👍", - "count": 1, - "selfReacted": false, - "pending": false - } - ], - "calls": [ - { - "op": "add", - "emoji": "👍" - } - ], - "errors": [ - "reactionFailed" - ] - }, - "note": "Nothing is queued: a failure discards the taps made while it was in flight too." - }, - { - "name": "summary-omits-emptied-emoji", - "self": "me", - "steps": [ - { - "op": "summary", - "reactions": [ - { - "emoji": "👍", - "count": 1, - "self": false, - "version": 1 - }, - { - "emoji": "🔥", - "count": 2, - "self": false, - "version": 3 - } - ] - }, - { - "op": "summary", - "reactions": [ - { - "emoji": "🔥", - "count": 2, - "self": false, - "version": 3 - } - ] - } - ], - "expect": { - "pills": [ - { - "emoji": "🔥", - "count": 2, - "selfReacted": false, - "pending": false - } - ], - "calls": [], - "errors": [] - }, - "note": "A later summary without 👍 means it emptied." - }, - { - "name": "summary-keeps-pending-emoji", - "self": "me", - "steps": [ - { - "op": "tap", - "emoji": "👍" - }, - { - "op": "summary", - "reactions": [ - { - "emoji": "🔥", - "count": 1, - "self": false, - "version": 1 - } - ] - } - ], - "expect": { - "pills": [ - { - "emoji": "👍", - "count": 1, - "selfReacted": true, - "pending": true - }, - { - "emoji": "🔥", - "count": 1, - "selfReacted": false, - "pending": false - } - ], - "calls": [ - { - "op": "add", - "emoji": "👍" - } - ], - "errors": [] - }, - "note": "A summary fetched before our add landed must not wipe the pending tap." - }, - { - "name": "reactions-are-per-emoji", - "self": "me", - "steps": [ - { - "op": "summary", - "reactions": [ - { - "emoji": "👍", - "count": 1, - "self": false, - "version": 9 - }, - { - "emoji": "🔥", - "count": 1, - "self": false, - "version": 2 - } - ] - }, - { - "op": "update", - "emoji": "🔥", - "actor": "ana", - "action": "ADDED", - "count": 2, - "version": 3 - } - ], - "expect": { - "pills": [ - { - "emoji": "🔥", - "count": 2, - "selfReacted": false, - "pending": false - }, - { - "emoji": "👍", - "count": 1, - "selfReacted": false, - "pending": false - } - ], - "calls": [], - "errors": [] - }, - "note": "Versions are per (message, emoji). 🔥 at 3 is fresh even though 👍 is at 9." - } - ], - "order": [ - { - "name": "count-descending", - "pills": [ - { - "emoji": "👍", - "count": 1, - "boostTotal": 0 - }, - { - "emoji": "🔥", - "count": 3, - "boostTotal": 0 - }, - { - "emoji": "😂", - "count": 2, - "boostTotal": 0 - } - ], - "expect": [ - "🔥", - "😂", - "👍" - ], - "note": "Phase 1: every boost total is 0, so count decides." - }, - { - "name": "boost-before-count", - "pills": [ - { - "emoji": "👍", - "count": 5, - "boostTotal": 0 - }, - { - "emoji": "💯", - "count": 2, - "boostTotal": 1000 - }, - { - "emoji": "👎", - "count": 1, - "boostTotal": 500 - } - ], - "expect": [ - "💯", - "👎", - "👍" - ], - "note": "Phase 2: any boost outranks any count. The unit is opaque here, only its order matters." - }, - { - "name": "tie-on-utf8-bytes", - "pills": [ - { - "emoji": "😂", - "count": 1, - "boostTotal": 0 - }, - { - "emoji": "👍", - "count": 1, - "boostTotal": 0 - }, - { - "emoji": "❤️", - "count": 1, - "boostTotal": 0 - } - ], - "expect": [ - "❤️", - "👍", - "😂" - ], - "note": "Ties fall back to the server's order, UTF-8 byte order. ❤️ starts E2, the others F0." - }, - { - "name": "tie-utf8-not-utf16", - "pills": [ - { - "emoji": "☝🏻", - "count": 1, - "boostTotal": 0 - }, - { - "emoji": "☝️", - "count": 1, - "boostTotal": 0 - } - ], - "expect": [ - "☝️", - "☝🏻" - ], - "note": "The case that separates byte order from a platform's default string compare. Both start U+261D. Next, ☝️ has U+FE0F (UTF-8 EF B8 8F) and ☝🏻 has U+1F3FB (F0 9F 8F BB): EF < F0 in UTF-8, but in UTF-16 the surrogate D83C < FE0F, which would flip them." - } - ], - "strip": [ - { - "name": "no-reactions", - "recents": [ - "😂", - "❤️", - "🔥", - "👍", - "😭", - "✨" - ], - "selfReactions": [], - "expect": [ - { - "emoji": "😂", - "highlighted": false - }, - { - "emoji": "❤️", - "highlighted": false - }, - { - "emoji": "🔥", - "highlighted": false - }, - { - "emoji": "👍", - "highlighted": false - }, - { - "emoji": "😭", - "highlighted": false - }, - { - "emoji": "✨", - "highlighted": false - } - ], - "note": "Recents, in rank order." - }, - { - "name": "reacted-with-a-recent", - "recents": [ - "😂", - "❤️", - "🔥", - "👍", - "😭", - "✨" - ], - "selfReactions": [ - { - "emoji": "🔥", - "reactedAt": 100 - } - ], - "expect": [ - { - "emoji": "😂", - "highlighted": false - }, - { - "emoji": "❤️", - "highlighted": false - }, - { - "emoji": "🔥", - "highlighted": true - }, - { - "emoji": "👍", - "highlighted": false - }, - { - "emoji": "😭", - "highlighted": false - }, - { - "emoji": "✨", - "highlighted": false - } - ], - "note": "A reaction already in the recents is highlighted in place, not moved." - }, - { - "name": "reacted-outside-recents", - "recents": [ - "😂", - "❤️", - "🔥", - "👍", - "😭", - "✨" - ], - "selfReactions": [ - { - "emoji": "💥", - "reactedAt": 100 - } - ], - "expect": [ - { - "emoji": "😂", - "highlighted": false - }, - { - "emoji": "❤️", - "highlighted": false - }, - { - "emoji": "🔥", - "highlighted": false - }, - { - "emoji": "👍", - "highlighted": false - }, - { - "emoji": "😭", - "highlighted": false - }, - { - "emoji": "✨", - "highlighted": false - }, - { - "emoji": "💥", - "highlighted": true - } - ], - "note": "After the six, next to +. The WhatsApp screenshot in the design review." - }, - { - "name": "several-outside-newest-first", - "recents": [ - "😂", - "❤️", - "🔥", - "👍", - "😭", - "✨" - ], - "selfReactions": [ - { - "emoji": "💥", - "reactedAt": 100 - }, - { - "emoji": "🦖", - "reactedAt": 300 - }, - { - "emoji": "🫠", - "reactedAt": 200 - } - ], - "expect": [ - { - "emoji": "😂", - "highlighted": false - }, - { - "emoji": "❤️", - "highlighted": false - }, - { - "emoji": "🔥", - "highlighted": false - }, - { - "emoji": "👍", - "highlighted": false - }, - { - "emoji": "😭", - "highlighted": false - }, - { - "emoji": "✨", - "highlighted": false - }, - { - "emoji": "🦖", - "highlighted": true - }, - { - "emoji": "🫠", - "highlighted": true - }, - { - "emoji": "💥", - "highlighted": true - } - ], - "note": "Newest reaction first, so the one you just made is the one visible before scrolling." - }, - { - "name": "mixed", - "recents": [ - "😂", - "❤️", - "🔥", - "👍", - "😭", - "✨" - ], - "selfReactions": [ - { - "emoji": "❤️", - "reactedAt": 50 - }, - { - "emoji": "🦖", - "reactedAt": 60 - } - ], - "expect": [ - { - "emoji": "😂", - "highlighted": false - }, - { - "emoji": "❤️", - "highlighted": true - }, - { - "emoji": "🔥", - "highlighted": false - }, - { - "emoji": "👍", - "highlighted": false - }, - { - "emoji": "😭", - "highlighted": false - }, - { - "emoji": "✨", - "highlighted": false - }, - { - "emoji": "🦖", - "highlighted": true - } - ], - "note": "Only the reaction outside the recents is appended." - } - ], - "recents": [ - { - "name": "empty-pads-defaults", - "uses": [], - "undrawable": [], - "limit": 6, - "expect": [ - "❤️", - "👍", - "😂", - "😮", - "😢", - "🔥" - ], - "note": "Nothing used yet: the defaults from node 10286:2573, in their order." - }, - { - "name": "frequency-first", - "uses": [ - { - "emoji": "🦖", - "usedAt": 1 - }, - { - "emoji": "🦖", - "usedAt": 2 - }, - { - "emoji": "🦖", - "usedAt": 3 - }, - { - "emoji": "🫠", - "usedAt": 4 - } - ], - "undrawable": [], - "limit": 6, - "expect": [ - "🦖", - "🫠", - "❤️", - "👍", - "😂", - "😮" - ], - "note": "Most used first, then padding with the defaults." - }, - { - "name": "recency-breaks-ties", - "uses": [ - { - "emoji": "🦖", - "usedAt": 1 - }, - { - "emoji": "🫠", - "usedAt": 2 - }, - { - "emoji": "🎉", - "usedAt": 3 - } - ], - "undrawable": [], - "limit": 6, - "expect": [ - "🎉", - "🫠", - "🦖", - "❤️", - "👍", - "😂" - ], - "note": "Equal counts: most recently used first." - }, - { - "name": "padding-skips-used-defaults", - "uses": [ - { - "emoji": "👍", - "usedAt": 1 - }, - { - "emoji": "👍", - "usedAt": 2 - } - ], - "undrawable": [], - "limit": 6, - "expect": [ - "👍", - "❤️", - "😂", - "😮", - "😢", - "🔥" - ], - "note": "A default already ranked is not repeated." - }, - { - "name": "undrawable-skipped", - "uses": [ - { - "emoji": "🫩", - "usedAt": 1 - }, - { - "emoji": "🫩", - "usedAt": 2 - }, - { - "emoji": "🦖", - "usedAt": 3 - } - ], - "undrawable": [ - "🫩" - ], - "limit": 6, - "expect": [ - "🦖", - "❤️", - "👍", - "😂", - "😮", - "😢" - ], - "note": "Emoji this device cannot draw are not offered, even if used on another OS version." - }, - { - "name": "picker-row-of-seven", - "uses": [], - "undrawable": [], - "limit": 7, - "expect": [ - "❤️", - "👍", - "😂", - "😮", - "😢", - "🔥" - ], - "note": "The picker asks for 7 and there are 6 defaults, so an unused picker shows 6. Not padded further: the row is not worth an invented seventh default." - }, - { - "name": "limit-truncates", - "uses": [ - { - "emoji": "🦖", - "usedAt": 0 - }, - { - "emoji": "🫠", - "usedAt": 1 - }, - { - "emoji": "🎉", - "usedAt": 2 - }, - { - "emoji": "💥", - "usedAt": 3 - }, - { - "emoji": "✨", - "usedAt": 4 - }, - { - "emoji": "😭", - "usedAt": 5 - }, - { - "emoji": "🥺", - "usedAt": 6 - }, - { - "emoji": "🙏", - "usedAt": 7 - } - ], - "undrawable": [], - "limit": 6, - "expect": [ - "🙏", - "🥺", - "😭", - "✨", - "💥", - "🎉" - ], - "note": "All used once, so recency orders them and the oldest two drop." - } - ], - "drawability": [ - { - "emoji": "😀", - "drawable": true, - "note": "Emoji 1.0, single code point." - }, - { - "emoji": "👍🏽", - "drawable": true, - "note": "Base plus skin-tone modifier." - }, - { - "emoji": "🧑🏻‍🤝‍🧑🏿", - "drawable": true, - "note": "Mixed-skin-tone ZWJ sequence, Emoji 12.1. The case a glyph-count rule gets wrong on iOS." - }, - { - "emoji": "🐈‍🦄", - "drawable": false, - "note": "Not an RGI sequence, so no font has a ligature; it draws as two emoji." - }, - { - "emoji": "🇦🇦", - "drawable": false, - "note": "Regional indicators that are not a flag draw as two letters." - } - ] -} diff --git a/test-vectors/README.md b/test-vectors/README.md index a1088bdd98..74d0f6f945 100644 --- a/test-vectors/README.md +++ b/test-vectors/README.md @@ -37,6 +37,7 @@ impl must reproduce the fixtures before the native duplicates are deleted. | `solana_message.json` | `:services:opencode` → `testDebugUnitTest` (host JVM) — **green** | `FlipcashCoreVectors` → xcodebuild on iOS Simulator — **green** | | `compact_message.json` | `:services:opencode` → `testDebugUnitTest` (host JVM) — **green** | `FlipcashCoreVectors` → xcodebuild on iOS Simulator — **green** | | `kikcode.json` + `kikcode_golden.svg` | `:libs:codes:kikcode` → `testAndroidHostTest` (host JVM) — **green** | *same Kotlin source* → `iosSimulatorArm64Test` (Kotlin/Native) — **green** | +| `reactions.json` | `:libs:emojis` + `:apps:flipcash:shared:chat` → `testDebugUnitTest` (host JVM); `drawability` via `:libs:emojis` androidTest → `connectedAndroidTest`. Read in place, no per-module copy. Canonical in the orchestrator (#17), not regenerated here | `FlipcashCoreVectors` → xcodebuild on iOS Simulator (copy of the orchestrator fixture) | Why the iOS split: **ed25519** lives in the standalone `CodeCurves` C package → host `swift test`. Everything else (**base58**, and later derivation/bonding curve) lives in **FlipcashCore**, whose diff --git a/apps/flipcash/shared/chat/src/test/resources/reactions.json b/test-vectors/reactions.json similarity index 100% rename from apps/flipcash/shared/chat/src/test/resources/reactions.json rename to test-vectors/reactions.json From d797fe37b154d0e287c5dc52c04832be0b30e5d1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 26 Sep 2026 07:36:06 -0400 Subject: [PATCH 55/58] fix(chat): line the reaction strip up with the bubble, not its row --- .../internal/screens/components/MessageRow.kt | 38 +++++++++++++------ .../shared/chat/ui/QuickReactionStrip.kt | 10 +++-- 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt index 05672c84bc..25e3a3d517 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt @@ -5,25 +5,25 @@ import androidx.compose.animation.EnterTransition import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.requiredSize -import androidx.compose.foundation.background import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons @@ -38,19 +38,24 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.draw.BlurredEdgeTreatment import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.roundToIntRect import androidx.paging.compose.LazyPagingItems import com.flipcash.app.messenger.internal.screens.ChatAnimations +import com.flipcash.features.messenger.R import com.flipcash.services.models.chat.BlobAccessContext import com.flipcash.services.models.chat.MessagePointer import com.flipcash.shared.chat.MessageCapability @@ -59,14 +64,13 @@ import com.flipcash.shared.chat.models.ChatListItem import com.flipcash.shared.chat.models.LocalChatActionHandler import com.flipcash.shared.chat.models.ReceiptStatus import com.flipcash.shared.chat.models.SeparatorConfig -import com.flipcash.features.messenger.R import com.flipcash.shared.chat.reactions.ReactionStrip import com.flipcash.shared.chat.ui.ContentBubble import com.flipcash.shared.chat.ui.QuickReactionStripPopup import com.flipcash.shared.chat.ui.ReactionPillRow +import com.flipcash.shared.chat.ui.bubblePositionOf import com.flipcash.shared.chat.ui.rendersBare import com.flipcash.shared.common.ui.ContactAvatar -import com.flipcash.shared.chat.ui.bubblePositionOf import com.getcode.theme.CodeTheme import com.getcode.ui.core.addIf import com.getcode.ui.utils.rememberKeyboardController @@ -306,7 +310,18 @@ internal fun MessageRow( horizontalAlignment = if (item.isFromSelf) Alignment.End else Alignment.Start, ) { Box(insertionModifier) { + val stripShown = selecting && focused && item.canReact && + quickReactionStrip.isNotEmpty() + // The strip lines up with the bubble as drawn, which sits inside + // a full-width layout, so it's measured here rather than taken + // from the popup's anchor. + var bubbleBounds by remember { mutableStateOf(null) } ContentBubble( + modifier = Modifier.addIf(stripShown) { + Modifier.onGloballyPositioned { + bubbleBounds = it.boundsInWindow().roundToIntRect() + } + }, item = item, // The bubble's own targets go with the row's: a cash // bubble behind the backdrop would otherwise open token @@ -329,9 +344,10 @@ internal fun MessageRow( // message is the selected one — it replaces the backdrop's own // reach for a reaction with something faster than opening the // picker, and disappears the moment selection moves off. - if (selecting && focused && item.canReact && quickReactionStrip.isNotEmpty()) { + if (stripShown) { QuickReactionStripPopup( entries = quickReactionStrip, + bubbleBounds = bubbleBounds, hugsTrailing = item.isFromSelf, onToggle = { emoji -> onAction( diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/QuickReactionStrip.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/QuickReactionStrip.kt index 5903328cc3..8974cdca14 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/QuickReactionStrip.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/QuickReactionStrip.kt @@ -221,7 +221,8 @@ fun QuickReactionStrip( } /** - * [QuickReactionStrip] in a popup anchored to the bubble this composable sits in, placed the way + * [QuickReactionStrip] in a popup placed against [bubbleBounds], the bubble as drawn in the window + * (lift included), falling back to the layout it sits in until that's measured. Placed the way * iOS places it: above the bubble with a 16dp gap (below when there's no room under [minTop]), * hugging the sender's side, and never within 16dp of the window's edges. */ @@ -232,9 +233,12 @@ fun QuickReactionStripPopup( onToggle: (emoji: String) -> Unit, onOpenPicker: () -> Unit, minTop: Dp, + bubbleBounds: IntRect? = null, ) { val density = LocalDensity.current - val provider = remember(hugsTrailing, minTop, density) { + // Keyed on the bounds so the popup is re-placed as the bubble lifts: the layout it sits in + // is the full-width row, whose edges aren't the bubble's. + val provider = remember(hugsTrailing, minTop, density, bubbleBounds) { object : PopupPositionProvider { override fun calculatePosition( anchorBounds: IntRect, @@ -243,7 +247,7 @@ fun QuickReactionStripPopup( popupContentSize: IntSize, ): IntOffset = with(density) { QuickReactionStripPlacement.position( - anchor = anchorBounds, + anchor = bubbleBounds ?: anchorBounds, window = windowSize, strip = popupContentSize, hugsTrailing = hugsTrailing, From 3e1fd2270efdba97c0443e9de7ef30675e9d93ac Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 26 Sep 2026 08:01:24 -0400 Subject: [PATCH 56/58] fix(messenger): lift only the bubble, leaving its reaction pills behind the backdrop The lift scaled the whole row, so a selected message's pills rose with its bubble. iOS lifts only the bubble view; the pills stay in the cell under the backdrop. Move the scale onto the bubble and give the selected row's pills the same dim and blur as the rest of the transcript. --- .../internal/screens/components/MessageRow.kt | 38 ++++++++++++------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt index 25e3a3d517..2804c9f40e 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt @@ -171,6 +171,27 @@ internal fun MessageRow( label = "messageLift", ) + // Only the bubble lifts, as iOS lifts only its bubble view out of the cell. Anchored to the + // bubble's own edge, as the insertion animation is, so it grows in place instead of sliding + // inward. + val liftModifier = Modifier.graphicsLayer { + scaleX = lift + scaleY = lift + transformOrigin = if (isOutgoing) TransformOrigin(1f, 0.5f) else TransformOrigin(0f, 0.5f) + } + + // The selected message's own pills stay behind the backdrop with the rest of the transcript, + // so they take the same dim and blur the other rows do. + val pillsBehindBackdrop = selecting && focused + val pillDimAlpha by animateFloatAsState( + targetValue = if (pillsBehindBackdrop) 0.4f else 1f, + label = "pillDim", + ) + val pillDimBlur by animateDpAsState( + targetValue = if (pillsBehindBackdrop) 8.dp else 0.dp, + label = "pillBlur", + ) + val swipe = rememberSwipeToReply( enabled = bubble != null && !selecting && @@ -192,18 +213,7 @@ internal fun MessageRow( // Unbounded: the rectangle treatment would clip the blur at the row's own // edges and leave a hard seam between neighbouring rows. .blur(dimBlur, BlurredEdgeTreatment.Unbounded) - .graphicsLayer { - alpha = dimAlpha - scaleX = lift - scaleY = lift - // Anchored to the bubble's own edge, as the insertion animation is, so - // the lift grows the bubble in place instead of sliding it inward. - transformOrigin = if (isOutgoing) { - TransformOrigin(1f, 0.5f) - } else { - TransformOrigin(0f, 0.5f) - } - } + .graphicsLayer { alpha = dimAlpha } // No row gestures while the backdrop is up: the rows are behind it, and a // press there would move the selection out from under the message the bar — // or the composer — is already acting on. @@ -309,7 +319,7 @@ internal fun MessageRow( modifier = Modifier.weight(1f), horizontalAlignment = if (item.isFromSelf) Alignment.End else Alignment.Start, ) { - Box(insertionModifier) { + Box(insertionModifier.then(liftModifier)) { val stripShown = selecting && focused && item.canReact && quickReactionStrip.isNotEmpty() // The strip lines up with the bubble as drawn, which sits inside @@ -383,6 +393,8 @@ internal fun MessageRow( BoxWithConstraints( modifier = Modifier .fillMaxWidth() + .blur(pillDimBlur, BlurredEdgeTreatment.Unbounded) + .graphicsLayer { alpha = pillDimAlpha } .addIf(selecting) { Modifier.blockPointerInput() }, ) { ReactionPillRow( From 57bd2563a4ac89d54b4880a20d62d903e1615a7b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 26 Sep 2026 08:49:58 -0400 Subject: [PATCH 57/58] fix(chat): seed the stream's reaction state from Room before writing it back A reaction arriving over the event stream built its message's state from nothing, then merged it into Room as a full summary. Every stored emoji the stream hadn't mentioned this session was tombstoned at its stored version, and GetReactionSummaries at that same version can't replace it. A message with 16 emoji showed 10 after one live reaction. --- .../internal/delegates/EventStreamDelegate.kt | 27 +++++++++- .../shared/chat/ChatCoordinatorEventsTest.kt | 49 +++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt index 0fea989412..2cb0403d0a 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt @@ -126,8 +126,31 @@ class EventStreamDelegate @Inject constructor( */ private val reactionStates: MutableMap> = mutableMapOf() - private fun reactionState(chatId: ChatId, messageId: Long): ReactionState = - reactionStates.getOrPut(chatId) { mutableMapOf() }.getOrPut(messageId) { ReactionState() } + /** + * Gets or creates the message's [ReactionState], seeding a new one from Room. The state is + * written back as a full summary, so an unseeded one would tombstone every stored emoji the + * stream hasn't mentioned this session, at the stored version, where a later summary at that + * same version can't bring it back. + */ + private suspend fun reactionState(chatId: ChatId, messageId: Long): ReactionState { + val chatStates = reactionStates.getOrPut(chatId) { mutableMapOf() } + return chatStates[messageId] ?: ReactionState().also { fresh -> + messageDataSource.getMessage(chatId, messageId)?.reactions?.let { summary -> + fresh.applySummary( + summary.reactions.map { reaction -> + ReactionState.SummaryEntry( + emoji = reaction.emoji.value, + count = reaction.count, + selfReacted = reaction.selfReactor != null, + version = reaction.version, + selfReactedAt = reaction.selfReactor?.reactedAt, + ) + }, + ) + } + chatStates[messageId] = fresh + } + } // region EventStreamOperations diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt index 661e469832..858cfd7200 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt @@ -15,7 +15,9 @@ import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatMutation import com.flipcash.services.models.chat.ChatUpdate import com.flipcash.services.models.chat.Emoji +import com.flipcash.services.models.chat.EmojiReaction import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.ReactionSummary import com.flipcash.services.models.chat.ReactionUpdate import com.flipcash.shared.chat.internal.ChatIdGenerator import com.flipcash.shared.chat.internal.ChatStateHolder @@ -558,6 +560,53 @@ class ChatCoordinatorEventsTest { } } + /** + * A stream update for a message whose reactions are already stored must not tombstone the + * stored emoji the update doesn't mention: the write is merged as a full summary, and a + * tombstone at the stored version can't be undone by a later summary at that same version. + */ + @Test + fun `reaction update keeps the stored emoji it doesn't mention`() = runTest(testDispatchers.dispatcher) { + tornDown { + coEvery { messageDataSource.getMessage(chatId, 1L) } returns textMessage(1L).copy( + reactions = ReactionSummary( + messageId = 1L, + reactions = listOf( + EmojiReaction(emoji = Emoji("❤️"), count = 1, selfReactor = null, sampleReactors = emptyList(), version = 5), + EmojiReaction(emoji = Emoji("😂"), count = 2, selfReactor = null, sampleReactors = emptyList(), version = 3), + ), + ), + ) + triggerCollection() + + chatUpdatesChannel.send(ChatUpdate( + chatId = chatId, + reactionUpdates = listOf( + ReactionUpdate( + messageId = 1L, + emoji = Emoji("🔥"), + actor = otherId, + action = ReactionUpdate.Action.ADDED, + count = 1, + version = 1, + reactedAt = Instant.fromEpochSeconds(1000), + ), + ), + )) + advanceTimeBy(500.milliseconds) + runCurrent() + + coVerify { + messageDataSource.mergeReactions(chatId, 1L, match { summary -> + summary.reactions.associate { it.emoji.value to it.count } == + mapOf("❤️" to 1L, "😂" to 2L, "🔥" to 1L) + }) + } + val overlay = coordinator.state.value.reactionOverlays[chatId]?.get(1L)?.reactions + assertEquals(setOf("❤️", "😂", "🔥"), overlay?.map { it.emoji.value }?.toSet()) + } + } + // endregion // region Session lifecycle From f93a20dac1d45131791b25bcb40d01d3b557e1f1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 26 Sep 2026 09:01:36 -0400 Subject: [PATCH 58/58] fix(chat): animate reaction pills in and out, clip their ripple, and duck the picker's category bar Pills scale and fade in and out, the row's height springs as pills come and go, and each chip is clipped before its click handling so the ripple stays in the capsule. The picker's category bar stops lifting at the top of the grid, so collapsing the sheet takes the bar down with it instead of lifting it over the search field. ChatAnimations moves to chat-ui so the pill row can share the reaction springs. --- .../internal/screens/ReactionPickerSheet.kt | 17 +- .../screens/components/ChatBottomBar.kt | 2 +- .../screens/components/MessageList.kt | 2 +- .../internal/screens/components/MessageRow.kt | 12 +- .../screens/components/ReceiptLabel.kt | 2 +- .../shared/chat/ui}/ChatAnimations.kt | 25 +- .../shared/chat/ui/ReactionPillRow.kt | 353 +++++++++++++++--- .../shared/chat/ui/ReactionPillRowTest.kt | 46 +++ 8 files changed, 391 insertions(+), 68 deletions(-) rename apps/flipcash/{features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens => shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui}/ChatAnimations.kt (68%) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactionPickerSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactionPickerSheet.kt index 6d5e25bdab..a7ee08a9fb 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactionPickerSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactionPickerSheet.kt @@ -48,6 +48,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember @@ -56,6 +57,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor @@ -223,13 +225,18 @@ private fun EmojiGrid( // An expandable sheet is laid out at its expanded height and slid down, so its bottom sits off // screen until it's fully up. Lifting the bar by that overhang keeps it hugging the screen at // either detent and mid-drag; it's read at placement so it moves in the same frame as the sheet. + // Collapsing past the half detent would lift it up into the search field, so the lift stops + // once the bar reaches the top of the grid, 12dp under the field, and from there the bar ducks + // off screen with the sheet. val sheetOverhang = LocalSheetOverhang.current + var gridHeightPx by remember { mutableIntStateOf(0) } Box(modifier = Modifier.fillMaxSize().navigationBarsPadding()) { LazyColumn( state = gridState, modifier = Modifier .fillMaxSize() + .onSizeChanged { gridHeightPx = it.height } .hazeSource(hazeState), contentPadding = PaddingValues( top = 4.dp, @@ -286,8 +293,13 @@ private fun EmojiGrid( }, modifier = Modifier .align(Alignment.BottomCenter) - .offset { IntOffset(0, -sheetOverhang().roundToInt()) } - .padding(bottom = 4.dp), + .offset { + val maxLift = gridHeightPx - + (CATEGORY_BAR_HEIGHT + CATEGORY_BAR_BOTTOM_GAP).roundToPx() + val lift = sheetOverhang().roundToInt().coerceIn(0, maxLift.coerceAtLeast(0)) + IntOffset(0, -lift) + } + .padding(bottom = CATEGORY_BAR_BOTTOM_GAP), ) } } @@ -438,6 +450,7 @@ private fun CategoryBar( private fun settleSpring() = spring(dampingRatio = 0.8f, stiffness = 320f) private val CATEGORY_BAR_HEIGHT = 45.dp +private val CATEGORY_BAR_BOTTOM_GAP = 4.dp private val INDICATOR_HEIGHT = 38.dp @Composable diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt index fe0b13faae..b3d7fd2b26 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt @@ -50,7 +50,7 @@ import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.app.messenger.internal.balanceRequirement import com.flipcash.app.messenger.internal.requiresStaff import com.flipcash.shared.chat.models.ChatActionHandler -import com.flipcash.app.messenger.internal.screens.ChatAnimations +import com.flipcash.shared.chat.ui.ChatAnimations import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.BlobAccessContext import com.flipcash.features.messenger.R diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index 76c2f7e5f2..43ea29d912 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -42,7 +42,7 @@ import androidx.paging.LoadState import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.itemKey import com.flipcash.app.messenger.internal.ChatViewModel -import com.flipcash.app.messenger.internal.screens.ChatAnimations +import com.flipcash.shared.chat.ui.ChatAnimations import com.flipcash.features.messenger.R import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.MessagePointer diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt index 2804c9f40e..a8ebcefbdd 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt @@ -54,7 +54,7 @@ import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.roundToIntRect import androidx.paging.compose.LazyPagingItems -import com.flipcash.app.messenger.internal.screens.ChatAnimations +import com.flipcash.shared.chat.ui.ChatAnimations import com.flipcash.features.messenger.R import com.flipcash.services.models.chat.BlobAccessContext import com.flipcash.services.models.chat.MessagePointer @@ -389,7 +389,14 @@ internal fun MessageRow( // bubble to, since the row doesn't expose its resolved width outward. // Kept on screen while selecting, as iOS keeps them under its // backdrop, but inert like every other target on the row. - if (item.reactionPills.isNotEmpty()) { + // + // Once a message has pills the row stays composed, so the last one can + // animate out; a message that gets its first reaction while on screen + // has its row animate that pill in rather than just appear. + val pillsAtFirstComposition = remember(item.messageId) { item.reactionPills.isNotEmpty() } + val pillRowComposed = remember(item.messageId) { BooleanArray(1) } + if (item.reactionPills.isNotEmpty()) pillRowComposed[0] = true + if (pillRowComposed[0]) { BoxWithConstraints( modifier = Modifier .fillMaxWidth() @@ -413,6 +420,7 @@ internal fun MessageRow( .align(if (item.isFromSelf) Alignment.TopEnd else Alignment.TopStart) .width(maxWidth * BUBBLE_ROW_WIDTH_FRACTION), alignEnd = item.isFromSelf, + animateInitialPills = !pillsAtFirstComposition, ) } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptLabel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptLabel.kt index fc21283176..82284637ac 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptLabel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptLabel.kt @@ -4,7 +4,7 @@ import android.text.format.DateFormat import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.snap -import com.flipcash.app.messenger.internal.screens.ChatAnimations +import com.flipcash.shared.chat.ui.ChatAnimations import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ChatAnimations.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatAnimations.kt similarity index 68% rename from apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ChatAnimations.kt rename to apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatAnimations.kt index 7652a2e713..3921cd81bd 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ChatAnimations.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatAnimations.kt @@ -1,4 +1,4 @@ -package com.flipcash.app.messenger.internal.screens +package com.flipcash.shared.chat.ui import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition @@ -13,9 +13,12 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.ui.Alignment import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.graphics.Color // All chat animation spring specs in one place. -internal object ChatAnimations { +object ChatAnimations { // Message bubble insertion — scale from 0.95 + opacity. // Matches iOS insertionSpring: .spring(duration: 0.23, bounce: 0.27). val insertion: SpringSpec = spring(dampingRatio = 0.73f, stiffness = 746f) @@ -68,4 +71,22 @@ internal object ChatAnimations { // Receipt label exit when a new message is sent — fade out + collapse. private val deliveredIntSize: SpringSpec = spring(dampingRatio = 0.88f, stiffness = 250f) val receiptExit: ExitTransition = shrinkVertically(deliveredIntSize) + fadeOut(delivered) + + // Reaction pills, from iOS ChatMotion's reaction springs and scales. + // A pill arriving — iOS reaction: .spring(duration: 0.32, bounce: 0.35), from 40%. + val reactionEnter: SpringSpec = spring(dampingRatio = 0.65f, stiffness = 386f) + const val reactionEnterScale = 0.4f + // A pill leaving — iOS reactionExit: .spring(duration: 0.2, bounce: 0), to 60%. Quicker than + // its arrival and without overshoot, so it doesn't read as the pill coming back. + val reactionExit: SpringSpec = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = 987f) + const val reactionExitScale = 0.6f + // Pills sliding to make room and the row resizing — iOS reactionReflow, which is insertion. + val reactionReflowOffset: SpringSpec = spring(dampingRatio = 0.73f, stiffness = 746f) + val reactionReflowHeight: SpringSpec = spring(dampingRatio = 0.73f, stiffness = 746f) + // A count or selected state changing in place — iOS reactionChange: .spring(duration: 0.24, bounce: 0). + val reactionChange: SpringSpec = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = 685f) + val reactionChangeColor: SpringSpec = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = 685f) + val reactionChangeDp: SpringSpec = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = 685f) + val reactionChangeOffset: SpringSpec = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = 685f) + val reactionChangeSize: SpringSpec = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = 685f) } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionPillRow.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionPillRow.kt index fee2802743..ed00c35b9b 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionPillRow.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionPillRow.kt @@ -1,5 +1,18 @@ package com.flipcash.shared.chat.ui +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.animation.core.AnimationVector2D +import androidx.compose.animation.core.VectorConverter +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.combinedClickable @@ -16,26 +29,43 @@ import androidx.compose.material.icons.outlined.AddReaction import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.layout.Placeable import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.node.LayoutModifierNode +import androidx.compose.ui.node.ModifierNodeElement import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.flipcash.core.R import com.flipcash.shared.chat.reactions.ReactionPill import com.getcode.theme.CodeTheme -import androidx.compose.ui.res.stringResource -import com.flipcash.core.R +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch /** * The N-more collapse the pill row uses under a bubble (decision 2): pack pills onto up to @@ -140,6 +170,7 @@ object ReactionPillRowLayout { } } + /** * The pill row drawn under a bubble (decision 2): up to two lines of [pills], collapsing into an * "N more" pill before the trailing "+" when they don't fit, aligned to [alignEnd] (the sender's @@ -149,8 +180,14 @@ object ReactionPillRowLayout { * select the message — its own `combinedClickable` handles the long-press so it never reaches the * bubble's. "N more" expands the row in place — every pill then draws, unbounded lines — and * stays expanded for the rest of this composition. "+" (hidden when `canReact` is false) opens - * the reaction picker. With no [pills] nothing draws: the long-press strip is how a first - * reaction gets added, as on iOS. + * the reaction picker. With no [pills] the row takes no space: the long-press strip is how a + * first reaction gets added, as on iOS. + * + * Changes animate as iOS's `ReactionPillRowView` does: a new pill grows in from 40% on a bouncy + * spring, a removed one shrinks to 60% and fades where it stood while the rest slide over, a count + * rolls up or down, and the row's height follows. The pills showing when the row first composes + * appear without animation, unless [animateInitialPills] says they're new — the caller passes it + * for a row that composes because its message just got its first reaction. */ @Composable fun ReactionPillRow( @@ -162,11 +199,14 @@ fun ReactionPillRow( modifier: Modifier = Modifier, alignEnd: Boolean = false, maxLines: Int = 2, + animateInitialPills: Boolean = false, ) { - // iOS draws no row at all until a message has a reaction; "+" only rides along with pills. - if (pills.isEmpty()) return + var expanded by remember { mutableStateOf(false) } - var expanded by remember(pills) { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val motion = remember { PillRowMotion(if (animateInitialPills) emptyList() else pills, canReact) } + val plusTarget = canReact && pills.isNotEmpty() + val rendered = remember(pills, plusTarget, motion.exitsFinished) { motion.update(pills, plusTarget) } val pillHeight = 28.dp val pillPadding = 10.dp @@ -174,40 +214,66 @@ fun ReactionPillRow( val plusSize = 28.dp val topGap = 4.dp - SubcomposeLayout(modifier = modifier.padding(top = topGap)) { constraints -> + SubcomposeLayout(modifier = modifier.animateHeightUnclipped()) { constraints -> val loose = constraints.copy(minWidth = 0, minHeight = 0) val spacingPx = spacingDp.roundToPx() + val topGapPx = topGap.roundToPx() val containerWidth = constraints.maxWidth val pillPlaceables = subcompose("pills") { - pills.forEach { pill -> - ReactionPillChip( - pill = pill, - height = pillHeight, - horizontalPadding = pillPadding, - // A previewer (canReact == false) can still open the reactors sheet from a - // long-press, but a tap must not toggle a reaction they're not allowed to make. - onClick = if (canReact) ({ onToggle(pill.emoji) }) else null, - onLongClick = onPillLongClick, - ) + rendered.pills.forEach { item -> + key(item.pill.emoji) { + EnterExit( + entering = item.entering, + exiting = item.exiting, + onExited = { motion.exited(item.pill.emoji) }, + ) { motionModifier -> + ReactionPillChip( + pill = item.pill, + height = pillHeight, + horizontalPadding = pillPadding, + // A previewer (canReact == false) can still open the reactors sheet + // from a long-press, but a tap must not toggle a reaction they're not + // allowed to make. A leaving pill takes neither. + onClick = if (canReact && !item.exiting) ({ onToggle(item.pill.emoji) }) else null, + onLongClick = if (item.exiting) ({}) else onPillLongClick, + modifier = motionModifier, + ) + } + } } }.map { it.measure(loose) } - val plusPlaceable = if (canReact) { - subcompose("plus") { PlusChip(size = plusSize, onClick = onOpenPicker) } - .first().measure(loose) - } else null + val plusPlaceable = rendered.plus?.let { plus -> + subcompose("plus") { + EnterExit( + entering = plus.entering, + exiting = plus.exiting, + onExited = { motion.exited(PLUS_KEY) }, + ) { motionModifier -> + PlusChip( + size = plusSize, + onClick = if (plus.exiting) ({}) else onOpenPicker, + modifier = motionModifier, + ) + } + }.first().measure(loose) + } + + // Leaving items keep the spot they held and take no room, so the rest reflow around them. + val flowIndices = rendered.pills.indices.filter { !rendered.pills[it].exiting } + val flowPlus = plusPlaceable?.takeIf { rendered.plus?.exiting == false } // Upper-bound estimate: a "more" chip sized for showing every pill, never narrower than // the real chip this could resolve to. val moreEstimate = subcompose("more-estimate") { - MorePillChip(count = pills.size, height = pillHeight, horizontalPadding = pillPadding) + MorePillChip(count = flowIndices.size, height = pillHeight, horizontalPadding = pillPadding) }.first().measure(loose) val result = ReactionPillRowLayout.compute( - pillWidths = pillPlaceables.map { it.width }, + pillWidths = flowIndices.map { pillPlaceables[it].width }, moreWidth = moreEstimate.width, - plusWidth = plusPlaceable?.width, + plusWidth = flowPlus?.width, containerWidth = containerWidth, spacing = spacingPx, maxLines = maxLines, @@ -225,14 +291,15 @@ fun ReactionPillRow( }.first().measure(loose) } else null - val visible = pillPlaceables.take(result.visibleCount) - val items = visible + listOfNotNull(morePlaceable, plusPlaceable) - // Greedy wrap identical to ReactionPillRowLayout.fits, now placing real placeables: first // assign each item an (x, line) within its line, tracking each line's total width and // height, then place with a per-line offset so an end-aligned row hugs the container's // trailing edge rather than its leading one. - data class Placement(val item: androidx.compose.ui.layout.Placeable, val x: Int, val line: Int) + class Item(val key: String, val placeable: Placeable) + class Placement(val item: Item, val x: Int, val line: Int) + + val items = flowIndices.take(result.visibleCount).map { Item(rendered.pills[it].pill.emoji, pillPlaceables[it]) } + + listOfNotNull(morePlaceable?.let { Item(MORE_KEY, it) }, flowPlus?.let { Item(PLUS_KEY, it) }) val placements = ArrayList(items.size) val lineWidths = ArrayList() @@ -242,7 +309,7 @@ fun ReactionPillRow( var lineHasItem = false var lineHeight = 0 for (item in items) { - val needed = item.width + if (lineHasItem) spacingPx else 0 + val needed = item.placeable.width + if (lineHasItem) spacingPx else 0 if (lineHasItem && x + needed > containerWidth) { lineWidths.add(x) lineHeights.add(lineHeight) @@ -253,8 +320,8 @@ fun ReactionPillRow( } if (lineHasItem) x += spacingPx placements.add(Placement(item, x, line)) - x += item.width - lineHeight = maxOf(lineHeight, item.height) + x += item.placeable.width + lineHeight = maxOf(lineHeight, item.placeable.height) lineHasItem = true } lineWidths.add(x) @@ -262,15 +329,164 @@ fun ReactionPillRow( val lineY = IntArray(lineHeights.size) for (i in 1 until lineHeights.size) lineY[i] = lineY[i - 1] + lineHeights[i - 1] + spacingPx - val totalHeight = lineY.last() + lineHeights.last() + // No pills in the flow means no row: not even the gap above it. + val totalHeight = if (items.isEmpty()) 0 else topGapPx + lineY.last() + lineHeights.last() + + val leaving = rendered.pills.mapIndexedNotNull { index, item -> + if (item.exiting) Item(item.pill.emoji, pillPlaceables[index]) else null + } + listOfNotNull(plusPlaceable?.takeIf { rendered.plus?.exiting == true }?.let { Item(PLUS_KEY, it) }) layout(containerWidth, totalHeight) { + val placed = HashSet() for (placement in placements) { val lineOffset = if (alignEnd) containerWidth - lineWidths[placement.line] else 0 - placement.item.placeRelative(placement.x + lineOffset, lineY[placement.line]) + val target = IntOffset(placement.x + lineOffset, topGapPx + lineY[placement.line]) + placement.item.placeable.placeRelative(motion.slide(placement.item.key, target, scope)) + placed += placement.item.key + } + for (item in leaving) { + val at = motion.lastPosition(item.key) ?: continue + item.placeable.placeRelative(at) + placed += item.key } + motion.placed(placed) + } + } +} + +private const val MORE_KEY = "\u0000more" +private const val PLUS_KEY = "\u0000plus" + +private class RenderedPill(val pill: ReactionPill, val entering: Boolean, val exiting: Boolean) +private class RenderedPlus(val entering: Boolean, val exiting: Boolean) +private class RenderedRow(val pills: List, val plus: RenderedPlus?) + +/** + * What the row draws across updates: the current pills plus any still animating out, each at the + * index it last held, and where every item was last placed so a leaving one can fade in place and + * the rest can slide from where they were. Plain fields, not state — they're written as the row + * composes and places, and only [exitsFinished] needs to trigger anything. + */ +private class PillRowMotion(initialPills: List, canReact: Boolean) { + private var shown: List = initialPills.map { RenderedPill(it, entering = false, exiting = false) } + private var plusShown: Boolean = canReact && initialPills.isNotEmpty() + private var plusLeaving: Boolean = false + private val finished = HashSet() + private var placedLastPass: Set = emptySet() + private val offsets = HashMap>() + + var exitsFinished by mutableIntStateOf(0) + private set + + fun update(pills: List, plusTarget: Boolean): RenderedRow { + val current = pills.mapTo(HashSet()) { it.emoji } + val before = shown.filter { !it.exiting }.mapTo(HashSet()) { it.pill.emoji } + val next = pills.mapTo(ArrayList()) { RenderedPill(it, entering = it.emoji !in before, exiting = false) } + shown.forEachIndexed { index, old -> + val emoji = old.pill.emoji + // Only a pill that was actually on screen has somewhere to fade out from; one folded + // into "N more" just goes. + if (emoji in current || emoji in finished || emoji !in placedLastPass) return@forEachIndexed + next.add(index.coerceAtMost(next.size), RenderedPill(old.pill, entering = false, exiting = true)) + } + finished.removeAll(current) + shown = next + + val plus = when { + plusTarget -> RenderedPlus(entering = !plusShown || plusLeaving, exiting = false) + .also { plusShown = true; plusLeaving = false } + plusShown && PLUS_KEY !in finished && PLUS_KEY in placedLastPass -> RenderedPlus(entering = false, exiting = true) + .also { plusLeaving = true } + else -> null.also { plusShown = false; plusLeaving = false; finished.remove(PLUS_KEY) } + } + return RenderedRow(next, plus) + } + + fun exited(key: String) { + finished += key + offsets.remove(key) + exitsFinished++ + } + + /** Where [key] should draw this frame on its way to [target]; a new key starts there. */ + fun slide(key: String, target: IntOffset, scope: CoroutineScope): IntOffset { + val offset = offsets.getOrPut(key) { Animatable(target, IntOffset.VectorConverter) } + if (offset.targetValue != target) scope.launch { offset.animateTo(target, ChatAnimations.reactionReflowOffset) } + return offset.value + } + + fun lastPosition(key: String): IntOffset? = offsets[key]?.value + + fun placed(keys: Set) { + placedLastPass = keys + offsets.keys.retainAll(keys) + } +} + +/** + * Scales and fades [content] in when [entering] (from 40%, overshooting on the arrival spring) and + * out when [exiting] (to 60%), calling [onExited] once it's gone. A return mid-exit springs back. + */ +@Composable +private fun EnterExit( + entering: Boolean, + exiting: Boolean, + onExited: () -> Unit, + content: @Composable (Modifier) -> Unit, +) { + val progress = remember { Animatable(if (entering) 0f else 1f) } + val currentOnExited by rememberUpdatedState(onExited) + LaunchedEffect(exiting) { + if (exiting) { + progress.animateTo(0f, ChatAnimations.reactionExit) + currentOnExited() + } else { + progress.animateTo(1f, ChatAnimations.reactionEnter) } } + content( + Modifier.graphicsLayer { + val p = progress.value + val from = if (exiting) ChatAnimations.reactionExitScale else ChatAnimations.reactionEnterScale + val scale = from + (1f - from) * p + scaleX = scale + scaleY = scale + alpha = p.coerceIn(0f, 1f) + }, + ) +} + +/** + * Animates the height this reports on the reflow spring, without the clip `animateContentSize` + * adds — a pill overshooting on arrival, or fading out below a shrinking row, draws past the + * bounds. The first measure (and the first after lazy-list reuse) takes its height as is. + */ +private fun Modifier.animateHeightUnclipped(): Modifier = this then AnimateHeightElement + +private data object AnimateHeightElement : ModifierNodeElement() { + override fun create() = AnimateHeightNode() + override fun update(node: AnimateHeightNode) = Unit +} + +private class AnimateHeightNode : Modifier.Node(), LayoutModifierNode { + private var height: Animatable? = null + + override fun onReset() { + height = null + } + + override fun onDetach() { + height = null + } + + override fun MeasureScope.measure(measurable: Measurable, constraints: Constraints): MeasureResult { + val placeable = measurable.measure(constraints) + val target = placeable.height + val animated = height ?: Animatable(target, Int.VectorConverter).also { height = it } + if (animated.targetValue != target) coroutineScope.launch { animated.animateTo(target, ChatAnimations.reactionReflowHeight) } + // The spring overshoots, and a row collapsing to nothing would dip below zero. + return layout(placeable.width, animated.value.coerceAtLeast(0)) { placeable.place(0, 0) } + } } @Composable @@ -280,24 +496,30 @@ private fun ReactionPillChip( horizontalPadding: Dp, onClick: (() -> Unit)?, onLongClick: () -> Unit, + modifier: Modifier = Modifier, ) { - val fill = if (pill.selfReacted) { - Color.White.copy(alpha = 0.12f) - } else { - Color.White.copy(alpha = 0.06f) - } + val shape = RoundedCornerShape(percent = 50) + val fill by animateColorAsState( + targetValue = Color.White.copy(alpha = if (pill.selfReacted) 0.12f else 0.06f), + animationSpec = ChatAnimations.reactionChangeColor, + label = "pill fill", + ) + val borderWidth by animateDpAsState( + targetValue = if (pill.selfReacted) CodeTheme.dimens.thickBorder else 0.dp, + animationSpec = ChatAnimations.reactionChangeDp, + label = "pill border", + ) Box( - modifier = Modifier + modifier = modifier .testTag("reaction_pill_${pill.emoji}") .semantics { selected = pill.selfReacted } - .background(color = fill, shape = RoundedCornerShape(percent = 50)) + // Clipped first so the press ripple stays inside the capsule. + .clip(shape) + .background(color = fill, shape = shape) .let { base -> - if (pill.selfReacted) { - base.border( - width = CodeTheme.dimens.thickBorder, - color = Color.White.copy(alpha = 0.24f), - shape = RoundedCornerShape(percent = 50), - ) + // A zero-width border would draw as a hairline, so none at all until it grows. + if (borderWidth > 0.dp) { + base.border(width = borderWidth, color = Color.White.copy(alpha = 0.24f), shape = shape) } else { base } @@ -313,12 +535,25 @@ private fun ReactionPillChip( verticalAlignment = Alignment.CenterVertically, ) { Text(text = pill.emoji, fontSize = 15.sp) - Text( - text = pill.count.toString(), - fontSize = 13.sp, - fontWeight = FontWeight.Medium, - color = CodeTheme.colors.textMain, - ) + // The count rolls to its next value like a counter turning: up for a rise, down for a + // fall. + AnimatedContent( + targetState = pill.count, + transitionSpec = { + val direction = if (targetState > initialState) 1 else -1 + (slideInVertically(ChatAnimations.reactionChangeOffset) { it * direction } + fadeIn(ChatAnimations.reactionChange)) + .togetherWith(slideOutVertically(ChatAnimations.reactionChangeOffset) { -it * direction } + fadeOut(ChatAnimations.reactionChange)) + .using(SizeTransform(clip = true) { _, _ -> ChatAnimations.reactionChangeSize }) + }, + label = "pill count", + ) { count -> + Text( + text = count.toString(), + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = CodeTheme.colors.textMain, + ) + } } } } @@ -330,16 +565,15 @@ private fun MorePillChip( horizontalPadding: Dp, onClick: (() -> Unit)? = null, ) { + val shape = RoundedCornerShape(percent = 50) Box( modifier = Modifier // Only the real "more" chip is tagged: the width-estimate slot SubcomposeLayout keeps // composed (to size the collapse ahead of the real decision) would otherwise carry the // same tag into the semantics tree even though it's never placed. .let { base -> if (onClick != null) base.testTag("reaction_pill_more") else base } - .background( - color = Color.White.copy(alpha = 0.06f), - shape = RoundedCornerShape(percent = 50), - ) + .clip(shape) + .background(color = Color.White.copy(alpha = 0.06f), shape = shape) .let { base -> onClick?.let { base.combinedClickable(onClick = it, onLongClick = {}) } ?: base } .height(height) .padding(horizontal = horizontalPadding), @@ -355,11 +589,12 @@ private fun MorePillChip( } @Composable -private fun PlusChip(size: Dp, onClick: () -> Unit) { +private fun PlusChip(size: Dp, onClick: () -> Unit, modifier: Modifier = Modifier) { Box( - modifier = Modifier + modifier = modifier .testTag("reaction_pill_plus") .size(size) + .clip(CircleShape) .background(color = Color.White.copy(alpha = 0.18f), shape = CircleShape) .combinedClickable(onClick = onClick, onLongClick = {}), contentAlignment = Alignment.Center, diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ReactionPillRowTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ReactionPillRowTest.kt index 56dc500bec..90755bbfa7 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ReactionPillRowTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ReactionPillRowTest.kt @@ -4,6 +4,10 @@ import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.width import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.runtime.setValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.getValue import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsNotDisplayed import androidx.compose.ui.test.assertIsNotSelected @@ -74,6 +78,48 @@ class ReactionPillRowTest { composeTestRule.onNodeWithTag("reaction_pill_plus").assertDoesNotExist() } + @Test + fun `a removed pill stays on screen while it animates out, then goes`() { + var pills by mutableStateOf(listOf(pill("😀"), pill("🎉"))) + composeTestRule.mainClock.autoAdvance = false + composeTestRule.setContent { + DesignSystem { + Box(Modifier.width(400.dp)) { + ReactionPillRow(pills = pills, canReact = true, onToggle = {}, onPillLongClick = {}, onOpenPicker = {}) + } + } + } + composeTestRule.mainClock.advanceTimeByFrame() + composeTestRule.onNodeWithTag("reaction_pill_🎉").assertIsDisplayed() + + pills = listOf(pill("😀")) + composeTestRule.mainClock.advanceTimeByFrame() + composeTestRule.onNodeWithTag("reaction_pill_🎉").assertExists() + + composeTestRule.mainClock.advanceTimeBy(1_000) + composeTestRule.onNodeWithTag("reaction_pill_🎉").assertDoesNotExist() + composeTestRule.onNodeWithTag("reaction_pill_😀").assertIsDisplayed() + } + + @Test + fun `losing the last pill collapses the row to no height`() { + var pills by mutableStateOf(listOf(pill("😀"))) + composeTestRule.setContent { + DesignSystem { + Box(Modifier.width(400.dp).testTag("container")) { + ReactionPillRow(pills = pills, canReact = true, onToggle = {}, onPillLongClick = {}, onOpenPicker = {}) + } + } + } + composeTestRule.onNodeWithTag("container").assertHeightIsEqualTo(32.dp) + + pills = emptyList() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("container").assertHeightIsEqualTo(0.dp) + composeTestRule.onNodeWithTag("reaction_pill_😀").assertDoesNotExist() + composeTestRule.onNodeWithTag("reaction_pill_plus").assertDoesNotExist() + } + @Test fun `plus is a 28dp circle`() { setRow(pills = listOf(pill("😀")))