Skip to content

Commit 2faa620

Browse files
authored
feat(Reactions): send emoji_code with reactions for push notification rendering (#3209)
### 🎯 Goal Reactions sent via `channel.sendReaction` did not include `emoji_code`, so mobile push notification templates (e.g. for `reaction.new`) had no emoji to render. This wires the React SDK to include `emoji_code` in the reaction request payload so mobile SDKs can render the correct emoji in push notifications. Linear: [REACT-880](https://linear.app/stream/issue/REACT-880/add-emoji-code-to-reaction-request-payload) ### 🛠 Implementation details - New exported helper `getEmojiCodeByReactionType(reactionOptions, type)` in `reactionOptions.tsx` resolves the native emoji character (e.g. `👍`) for a reaction type from the option's existing `unicode` field via `unicodeToEmoji()`. Returns `undefined` for legacy array options or options that omit `unicode`, so we never send a garbage code. - `useReactionHandler` now reads `reactionOptions` from `ComponentContext` (falling back to `defaultReactionOptions`, like other consumers) and includes `emoji_code` in the `sendReaction` payload when one resolves: ```ts channel.sendReaction(id, { type, ...(emojiCode && { emoji_code: emojiCode }) }); ``` The optimistic reaction preview is stamped with the same value for consistency. The public `handleReaction(type, event)` signature is **unchanged**. - `mapEmojiMartData` now stores `unicode` on each mapped entry, so emoji picked from the **extended** (emoji-mart) list also resolve an `emoji_code`. - The `stream-chat` `Reaction` type already supports the optional `emoji_code` field — only stream-chat-react needed to populate it. **Behavior:** default reactions (`like`, `love`, `haha`, `sad`, `wow`, `fire`) and any custom reactions defining `unicode` now ship `emoji_code` automatically. Legacy array-form reaction options carry no unicode data and continue to send no `emoji_code` (graceful, unchanged otherwise). **Tests:** new `reactionOptions.test.ts` covering the helper (quick/extended/legacy-array/unknown/missing-unicode) and `mapEmojiMartData`; `useReactionHandler` tests for default/custom/no-unicode payloads and the optimistic preview; updated the existing `Message.test.tsx` send assertion to the new contract. ### 🎨 UI Changes None — this only affects the network payload sent with reactions (and the local optimistic reaction object). No visual changes. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Release Notes * **New Features** * Reactions now support and transmit emoji code data for improved emoji handling. * **Tests** * Expanded test coverage for reaction emoji code derivation with custom reaction options. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent f7cfa8a commit 2faa620

5 files changed

Lines changed: 155 additions & 9 deletions

File tree

src/components/Message/__tests__/Message.test.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,10 @@ describe('<Message /> component', () => {
227227
});
228228

229229
await context.handleReaction(reaction.type);
230-
expect(sendReaction).toHaveBeenCalledWith(message.id, { type: reaction.type });
230+
expect(sendReaction).toHaveBeenCalledWith(message.id, {
231+
emoji_code: '❤️',
232+
type: reaction.type,
233+
});
231234
});
232235

233236
it('should not send reaction without permission', async () => {

src/components/Message/hooks/__tests__/useReactionHandler.test.tsx

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { reactionHandlerWarning, useReactionHandler } from '../useReactionHandle
77
import { ChannelActionProvider } from '../../../../context/ChannelActionContext';
88
import { ChannelStateProvider } from '../../../../context/ChannelStateContext';
99
import { ChatProvider } from '../../../../context/ChatContext';
10+
import { ComponentProvider } from '../../../../context/ComponentContext';
11+
import { emojiToUnicode } from '../../../Reactions/reactionOptions';
1012
import {
1113
generateChannel,
1214
generateMessage,
@@ -30,12 +32,14 @@ async function renderUseReactionHandlerHook(
3032
params: {
3133
channelContextProps?: Record<string, unknown>;
3234
channelStateContextOverrides?: Record<string, unknown>;
35+
componentContext?: Record<string, unknown>;
3336
message?: LocalMessage | null;
3437
} = {},
3538
) {
3639
const {
3740
channelContextProps = {},
3841
channelStateContextOverrides = {},
42+
componentContext = {},
3943
message = generateMessage(),
4044
} = params;
4145

@@ -58,7 +62,7 @@ async function renderUseReactionHandlerHook(
5862
})}
5963
>
6064
<ChannelActionProvider value={mockChannelActionContext({ updateMessage })}>
61-
{children}
65+
<ComponentProvider value={componentContext}>{children}</ComponentProvider>
6266
</ChannelActionProvider>
6367
</ChannelStateProvider>
6468
</ChatProvider>
@@ -103,13 +107,56 @@ describe('useReactionHandler custom hook', () => {
103107
expect(deleteReaction).toHaveBeenCalledWith(message.id, reaction.type);
104108
});
105109

106-
it('should send reaction', async () => {
107-
const reaction = generateReaction({ user: bob });
110+
it('should send reaction with emoji_code derived from the default reaction options', async () => {
108111
const message = generateMessage({ own_reactions: [] });
109112
const handleReaction = await renderUseReactionHandlerHook({ message });
110-
await handleReaction(reaction.type);
113+
await handleReaction('love');
111114
expect(sendReaction).toHaveBeenCalledWith(message.id, {
112-
type: reaction.type,
115+
emoji_code: '❤️',
116+
type: 'love',
117+
});
118+
});
119+
120+
it('should send reaction without emoji_code when the type has no unicode', async () => {
121+
const message = generateMessage({ own_reactions: [] });
122+
const handleReaction = await renderUseReactionHandlerHook({ message });
123+
await handleReaction('unsupported-reaction-type');
124+
expect(sendReaction).toHaveBeenCalledWith(message.id, {
125+
type: 'unsupported-reaction-type',
126+
});
127+
});
128+
129+
it('should derive emoji_code from custom reaction options provided via context', async () => {
130+
const message = generateMessage({ own_reactions: [] });
131+
const handleReaction = await renderUseReactionHandlerHook({
132+
componentContext: {
133+
reactionOptions: {
134+
quick: {
135+
rocket: {
136+
Component: () => null,
137+
name: 'Rocket',
138+
unicode: emojiToUnicode('🚀'),
139+
},
140+
},
141+
},
142+
},
143+
message,
144+
});
145+
await handleReaction('rocket');
146+
expect(sendReaction).toHaveBeenCalledWith(message.id, {
147+
emoji_code: '🚀',
148+
type: 'rocket',
149+
});
150+
});
151+
152+
it('should stamp emoji_code on the optimistic reaction preview', async () => {
153+
const message = generateMessage({ own_reactions: [] });
154+
const handleReaction = await renderUseReactionHandlerHook({ message });
155+
await handleReaction('love');
156+
const optimisticMessage = updateMessage.mock.calls[0][0];
157+
expect(optimisticMessage.latest_reactions[0]).toMatchObject({
158+
emoji_code: '❤️',
159+
type: 'love',
113160
});
114161
});
115162

src/components/Message/hooks/useReactionHandler.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ import { useThreadContext } from '../../Threads';
66
import { useChannelActionContext } from '../../../context/ChannelActionContext';
77
import { useChannelStateContext } from '../../../context/ChannelStateContext';
88
import { useChatContext } from '../../../context/ChatContext';
9+
import { useComponentContext } from '../../../context/ComponentContext';
10+
import {
11+
defaultReactionOptions,
12+
getEmojiCodeByReactionType,
13+
} from '../../Reactions/reactionOptions';
914

1015
import type { LocalMessage, Reaction, ReactionResponse } from 'stream-chat';
1116

@@ -17,6 +22,8 @@ export const useReactionHandler = (message?: LocalMessage) => {
1722
const { updateMessage } = useChannelActionContext('useReactionHandler');
1823
const { channel, channelCapabilities } = useChannelStateContext('useReactionHandler');
1924
const { client } = useChatContext('useReactionHandler');
25+
const { reactionOptions = defaultReactionOptions } =
26+
useComponentContext('useReactionHandler');
2027

2128
const createMessagePreview = useCallback(
2229
(add: boolean, reaction: ReactionResponse, message: LocalMessage): LocalMessage => {
@@ -69,26 +76,33 @@ export const useReactionHandler = (message?: LocalMessage) => {
6976
[client.user, client.userID],
7077
);
7178

72-
const createReactionPreview = (type: string) => ({
79+
const createReactionPreview = (type: string, emojiCode?: string) => ({
7380
message_id: message?.id,
7481
score: 1,
7582
type,
7683
user: client.user,
7784
user_id: client.user?.id,
85+
...(emojiCode && { emoji_code: emojiCode }),
7886
});
7987

8088
const toggleReaction = throttle(async (id: string, type: string, add: boolean) => {
8189
if (!message || !channelCapabilities['send-reaction']) return;
8290

83-
const newReaction = createReactionPreview(type) as ReactionResponse;
91+
// Native emoji (e.g. "👍") for this reaction type, sent as `emoji_code` so
92+
// push notifications in mobile SDKs can render the emoji.
93+
const emojiCode = getEmojiCodeByReactionType(reactionOptions, type);
94+
const newReaction = createReactionPreview(type, emojiCode) as ReactionResponse;
8495
const tempMessage = createMessagePreview(add, newReaction, message);
8596

8697
try {
8798
updateMessage(tempMessage);
8899
thread?.upsertReplyLocally({ message: tempMessage });
89100

90101
const messageResponse = add
91-
? await channel.sendReaction(id, { type } as Reaction)
102+
? await channel.sendReaction(id, {
103+
type,
104+
...(emojiCode && { emoji_code: emojiCode }),
105+
} as Reaction)
92106
: await channel.deleteReaction(id, type);
93107

94108
// seems useless as we're expecting WS event to come in and replace this anyway
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import {
2+
defaultReactionOptions,
3+
emojiToUnicode,
4+
getEmojiCodeByReactionType,
5+
mapEmojiMartData,
6+
} from '../reactionOptions';
7+
8+
const noop = () => null;
9+
10+
describe('getEmojiCodeByReactionType', () => {
11+
it('returns the native emoji for a quick reaction type', () => {
12+
expect(getEmojiCodeByReactionType(defaultReactionOptions, 'like')).toBe('👍');
13+
expect(getEmojiCodeByReactionType(defaultReactionOptions, 'love')).toBe('❤️');
14+
expect(getEmojiCodeByReactionType(defaultReactionOptions, 'haha')).toBe('😂');
15+
});
16+
17+
it('returns the native emoji for an extended reaction type', () => {
18+
const reactionOptions = {
19+
extended: {
20+
rocket: { Component: noop, name: 'Rocket', unicode: emojiToUnicode('🚀') },
21+
},
22+
quick: {},
23+
};
24+
25+
expect(getEmojiCodeByReactionType(reactionOptions, 'rocket')).toBe('🚀');
26+
});
27+
28+
it('returns undefined for an unknown reaction type', () => {
29+
expect(getEmojiCodeByReactionType(defaultReactionOptions, 'does-not-exist')).toBe(
30+
undefined,
31+
);
32+
});
33+
34+
it('returns undefined when the matched option has no unicode', () => {
35+
const reactionOptions = { quick: { custom: { Component: noop, name: 'Custom' } } };
36+
37+
expect(getEmojiCodeByReactionType(reactionOptions, 'custom')).toBe(undefined);
38+
});
39+
40+
it('returns undefined for legacy array reaction options (no unicode data)', () => {
41+
const reactionOptions = [{ Component: noop, name: 'Like', type: 'like' }];
42+
43+
expect(getEmojiCodeByReactionType(reactionOptions, 'like')).toBe(undefined);
44+
});
45+
});
46+
47+
describe('mapEmojiMartData', () => {
48+
it('stores the unicode code point on each mapped entry', () => {
49+
const mapped = mapEmojiMartData({
50+
emojis: {
51+
joy: { name: 'Joy', skins: [{ native: '😂' }] },
52+
},
53+
});
54+
55+
const unicode = emojiToUnicode('😂');
56+
expect(mapped[unicode].unicode).toBe(unicode);
57+
expect(mapped[unicode].name).toBe('Joy');
58+
});
59+
});

src/components/Reactions/reactionOptions.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export const mapEmojiMartData = (
4848
newMap[unicode] = {
4949
Component: () => <>{nativeEmoji}</>,
5050
name: emojiData.name,
51+
unicode,
5152
};
5253
}
5354

@@ -111,3 +112,25 @@ export const getHasExtendedReactions = (reactionOptions: ReactionOptions) =>
111112
!Array.isArray(reactionOptions) &&
112113
typeof reactionOptions.extended !== 'undefined' &&
113114
Object.keys(reactionOptions.extended).length > 0;
115+
116+
/**
117+
* Resolves the native emoji character (e.g. "👍") for a given reaction type from
118+
* the configured reaction options. The value is used as the `emoji_code` sent
119+
* with a reaction so that push notifications can render the emoji.
120+
*
121+
* Returns `undefined` when no `unicode` is available for the type (e.g. legacy
122+
* array reaction options or custom options that omit `unicode`).
123+
*/
124+
export const getEmojiCodeByReactionType = (
125+
reactionOptions: ReactionOptions,
126+
reactionType: string,
127+
): string | undefined => {
128+
// Legacy array reaction options carry no unicode data.
129+
if (Array.isArray(reactionOptions)) return undefined;
130+
131+
const unicode =
132+
reactionOptions.quick[reactionType]?.unicode ??
133+
reactionOptions.extended?.[reactionType]?.unicode;
134+
135+
return unicode ? unicodeToEmoji(unicode) : undefined;
136+
};

0 commit comments

Comments
 (0)