From 3dbb851f61f0bbf83e2b517eda6b7194548e515d Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 24 Jul 2026 00:07:19 -0700 Subject: [PATCH 1/2] fix(reactions): accept emoji with variation selectors / ZWJ / skin tones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAFE_EMOJI_RE was /^[\p{Emoji}‍]{1,8}$/u. `\p{Emoji}` does NOT match the combining marks real emoji carry — U+FE0F (variation selector), skin-tone modifiers — so ❤️ (U+2764 U+FE0F), which is in the client's reaction palette, 400'd with "emoji must be 1–8 emoji characters". The frontend swallows reaction errors, so clicking ❤️ did nothing: users perceived "reactions can't show more than one" (their obvious second reaction, love, silently failed). Confirmed live against the API on 2026-07-24; the backend count aggregation and the UI count render were both verified correct — this validation was the whole bug. Fix: widen the char class to allow U+FE0F, U+200D (ZWJ sequences), and \p{Emoji_Modifier} (skin tones); raise the cap to 16 code points for sequences. Test: reactionController.emojiValidation.test.js — ❤️, 👍🏽, 👨‍👩‍👧 accepted; plain non-emoji still 400s. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DMeWzgFxsfBcjoLVLewEES --- ...reactionController.emojiValidation.test.js | 66 +++++++++++++++++++ backend/controllers/reactionController.ts | 9 ++- 2 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 backend/__tests__/unit/controllers/reactionController.emojiValidation.test.js diff --git a/backend/__tests__/unit/controllers/reactionController.emojiValidation.test.js b/backend/__tests__/unit/controllers/reactionController.emojiValidation.test.js new file mode 100644 index 000000000..9afa04194 --- /dev/null +++ b/backend/__tests__/unit/controllers/reactionController.emojiValidation.test.js @@ -0,0 +1,66 @@ +// Guards SAFE_EMOJI_RE after the 2026-07-24 bug: `\p{Emoji}` alone rejected the +// combining marks real emoji carry — U+FE0F (variation selector, e.g. ❤️), +// U+200D (ZWJ sequences), skin-tone modifiers — so ❤️ (in the client palette) +// 400'd and the frontend swallowed it. Users couldn't add it → "reactions can't +// show more than one." These pin that the palette + common sequences are +// accepted, and that genuine non-emoji is still rejected. + +jest.mock('../../../models/pg/MessageReaction', () => ({ + __esModule: true, + default: { + add: jest.fn().mockResolvedValue(undefined), + remove: jest.fn().mockResolvedValue(undefined), + listForMessage: jest.fn().mockResolvedValue([{ emoji: '👍', count: 1, mine: true, userIds: ['caller-1'] }]), + }, +})); +jest.mock('../../../services/reactionAttributionService', () => ({ + decorateReactionSummaries: jest.fn(async (s) => s), +})); +jest.mock('../../../config/db-pg', () => ({ pool: { query: jest.fn() } })); +jest.mock('../../../models/AgentRegistry', () => ({ AgentInstallation: { findOne: jest.fn() } })); +jest.mock('../../../models/Pod', () => ({ findById: jest.fn() })); +jest.mock('../../../config/socket', () => ({ getIO: jest.fn() })); + +const reactionController = require('../../../controllers/reactionController'); +const MessageReaction = require('../../../models/pg/MessageReaction').default; +const { pool } = require('../../../config/db-pg'); +const { AgentInstallation } = require('../../../models/AgentRegistry'); +const socketConfig = require('../../../config/socket'); + +const buildRes = () => { + const res = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + return res; +}; + +const reactAs = async (emoji) => { + // loadPodIdForMessage → a pod; agent path → active install → access granted. + pool.query.mockResolvedValueOnce({ rows: [{ pod_id: 'pod-1' }], rowCount: 1 }); + AgentInstallation.findOne.mockReturnValue({ lean: () => Promise.resolve({ _id: 'inst-1' }) }); + const req = { params: { messageId: '9' }, body: { emoji }, agentUser: { _id: 'bot-1' } }; + const res = buildRes(); + await reactionController.addReaction(req, res); + return res; +}; + +describe('reactionController.addReaction — emoji validation (❤️ bug 2026-07-24)', () => { + beforeEach(() => { + jest.clearAllMocks(); + socketConfig.getIO.mockReturnValue({ to: jest.fn().mockReturnValue({ emit: jest.fn() }) }); + }); + + // ❤️ = U+2764 U+FE0F; 👍🏽 = thumb + skin-tone modifier; 👨‍👩‍👧 = ZWJ sequence; + // 👍 = plain single code point (control that always worked). + test.each(['👍', '❤️', '👍🏽', '👨‍👩‍👧'])('accepts %s', async (emoji) => { + const res = await reactAs(emoji); + expect(res.status).not.toHaveBeenCalledWith(400); + expect(MessageReaction.add).toHaveBeenCalledWith('9', 'bot-1', emoji); + }); + + test.each(['abc', 'a👍', '