Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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👍', '<script>'])('rejects non-emoji %p with 400', async (emoji) => {
const res = await reactAs(emoji);
expect(res.status).toHaveBeenCalledWith(400);
expect(MessageReaction.add).not.toHaveBeenCalled();
});
});
9 changes: 8 additions & 1 deletion backend/controllers/reactionController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,14 @@ interface AuthedRes {
json: (d: unknown) => void;
}

const SAFE_EMOJI_RE = /^[\p{Emoji}‍]{1,8}$/u;
// Accept a single emoji OR a full emoji sequence. `\p{Emoji}` alone rejects
// the combining marks real emoji carry: U+FE0F (variation selector — makes
// ❤️/☺️/⭐️ render as emoji), U+200D (ZWJ, for 👨‍👩‍👧 family/profession
// sequences), and skin-tone modifiers (\p{Emoji_Modifier}). Before this, ❤️ —
// which is in the client's reaction palette — 400'd ("emoji must be 1–8…") and
// the frontend swallowed it, so users couldn't add it (2026-07-24). Widened to
// 16 code points so ZWJ sequences fit.
const SAFE_EMOJI_RE = /^[\p{Emoji}\u{200D}\u{FE0F}\p{Emoji_Modifier}]{1,16}$/u;

function getUserId(req: AuthedReq): string {
return String(req.user?._id || req.userId || req.agentUser?._id || '');
Expand Down
19 changes: 18 additions & 1 deletion frontend/src/v2/components/V2MessageBubble.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,11 @@ const V2MessageBubble: React.FC<V2MessageBubbleProps> = ({ message, isLead, agen
// Picker state per message. Open via "+ react"; close on first click
// (no need for outside-click handling — the picker hides after action).
const [pickerOpen, setPickerOpen] = useState(false);
// Surface why a reaction failed instead of swallowing it. Before this, a
// rejected reaction (bad emoji 400, non-member 403, rate-limit 429) did
// nothing visible — which made the ❤️-validation bug read as "reactions
// don't work / can't add more than one" (2026-07-24).
const [reactionError, setReactionError] = useState<string | null>(null);
const rawUsername = message.user?.username || 'Unknown';
const overriddenDisplay = agentDisplayNames?.get(rawUsername);
const author = overriddenDisplay || rawUsername;
Expand Down Expand Up @@ -478,7 +483,16 @@ const V2MessageBubble: React.FC<V2MessageBubbleProps> = ({ message, isLead, agen
// Optimistic update is unnecessary — the socket `messageReaction`
// event from the backend updates the message list in place.
} catch (err) {
// Defensive: ignore (likely 429 or 403); next list refresh corrects.
const e = err as { response?: { status?: number; data?: { msg?: string; error?: string } } };
const status = e.response?.status;
const serverMsg = e.response?.data?.msg || e.response?.data?.error;
setReactionError(
serverMsg
|| (status === 403 ? "You're not a member of this pod." : '')
|| (status === 429 ? 'Too many reactions — give it a moment.' : '')
|| 'Could not add that reaction.',
);
window.setTimeout(() => setReactionError(null), 4000);
// eslint-disable-next-line no-console
console.warn('[reactions] toggle failed:', (err as Error).message);
} finally {
Expand Down Expand Up @@ -564,6 +578,9 @@ const V2MessageBubble: React.FC<V2MessageBubbleProps> = ({ message, isLead, agen
)}
</span>
)}
{reactionError && (
<span className="v2-msg__reaction-error" role="alert">{reactionError}</span>
)}
</div>
);
})()}
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/v2/v2.css
Original file line number Diff line number Diff line change
Expand Up @@ -5645,6 +5645,13 @@
font-weight: 600;
font-size: 11px;
}
/* Transient reason-a-reaction-failed line (bad emoji / not a member / rate
* limited). Auto-clears after ~4s from the component. */
.v2-msg__reaction-error {
font-size: 11px;
color: var(--v2-danger, #c0392b);
align-self: center;
}

/* Sprint B5 — interactive reaction chips + picker. Mine = filled
* indigo; not-mine = subtle gray (existing rule above). Same
Expand Down
Loading