From dcc1431d3c9de8ca48d62c54d008d34dcf04586c Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Fri, 5 Jun 2026 11:03:08 +0200 Subject: [PATCH] feat(Reactions): send emoji_code with reactions for push notification rendering Resolve the native emoji for a reaction type from the configured reaction options and include it as `emoji_code` in the `sendReaction` payload so mobile push notification templates can render the emoji. Defaults and any custom reaction options defining `unicode` are supported automatically; legacy array options and options without `unicode` send no `emoji_code`. No public API change. REACT-880 --- .../Message/__tests__/Message.test.tsx | 5 +- .../__tests__/useReactionHandler.test.tsx | 57 ++++++++++++++++-- .../Message/hooks/useReactionHandler.ts | 20 ++++++- .../__tests__/reactionOptions.test.ts | 59 +++++++++++++++++++ src/components/Reactions/reactionOptions.tsx | 23 ++++++++ 5 files changed, 155 insertions(+), 9 deletions(-) create mode 100644 src/components/Reactions/__tests__/reactionOptions.test.ts diff --git a/src/components/Message/__tests__/Message.test.tsx b/src/components/Message/__tests__/Message.test.tsx index 688d349dc8..9d35a2a8cf 100644 --- a/src/components/Message/__tests__/Message.test.tsx +++ b/src/components/Message/__tests__/Message.test.tsx @@ -227,7 +227,10 @@ describe(' component', () => { }); await context.handleReaction(reaction.type); - expect(sendReaction).toHaveBeenCalledWith(message.id, { type: reaction.type }); + expect(sendReaction).toHaveBeenCalledWith(message.id, { + emoji_code: '❤️', + type: reaction.type, + }); }); it('should not send reaction without permission', async () => { diff --git a/src/components/Message/hooks/__tests__/useReactionHandler.test.tsx b/src/components/Message/hooks/__tests__/useReactionHandler.test.tsx index cc5780480d..6a67a418a4 100644 --- a/src/components/Message/hooks/__tests__/useReactionHandler.test.tsx +++ b/src/components/Message/hooks/__tests__/useReactionHandler.test.tsx @@ -7,6 +7,8 @@ import { reactionHandlerWarning, useReactionHandler } from '../useReactionHandle import { ChannelActionProvider } from '../../../../context/ChannelActionContext'; import { ChannelStateProvider } from '../../../../context/ChannelStateContext'; import { ChatProvider } from '../../../../context/ChatContext'; +import { ComponentProvider } from '../../../../context/ComponentContext'; +import { emojiToUnicode } from '../../../Reactions/reactionOptions'; import { generateChannel, generateMessage, @@ -30,12 +32,14 @@ async function renderUseReactionHandlerHook( params: { channelContextProps?: Record; channelStateContextOverrides?: Record; + componentContext?: Record; message?: LocalMessage | null; } = {}, ) { const { channelContextProps = {}, channelStateContextOverrides = {}, + componentContext = {}, message = generateMessage(), } = params; @@ -58,7 +62,7 @@ async function renderUseReactionHandlerHook( })} > - {children} + {children} @@ -103,13 +107,56 @@ describe('useReactionHandler custom hook', () => { expect(deleteReaction).toHaveBeenCalledWith(message.id, reaction.type); }); - it('should send reaction', async () => { - const reaction = generateReaction({ user: bob }); + it('should send reaction with emoji_code derived from the default reaction options', async () => { const message = generateMessage({ own_reactions: [] }); const handleReaction = await renderUseReactionHandlerHook({ message }); - await handleReaction(reaction.type); + await handleReaction('love'); expect(sendReaction).toHaveBeenCalledWith(message.id, { - type: reaction.type, + emoji_code: '❤️', + type: 'love', + }); + }); + + it('should send reaction without emoji_code when the type has no unicode', async () => { + const message = generateMessage({ own_reactions: [] }); + const handleReaction = await renderUseReactionHandlerHook({ message }); + await handleReaction('unsupported-reaction-type'); + expect(sendReaction).toHaveBeenCalledWith(message.id, { + type: 'unsupported-reaction-type', + }); + }); + + it('should derive emoji_code from custom reaction options provided via context', async () => { + const message = generateMessage({ own_reactions: [] }); + const handleReaction = await renderUseReactionHandlerHook({ + componentContext: { + reactionOptions: { + quick: { + rocket: { + Component: () => null, + name: 'Rocket', + unicode: emojiToUnicode('🚀'), + }, + }, + }, + }, + message, + }); + await handleReaction('rocket'); + expect(sendReaction).toHaveBeenCalledWith(message.id, { + emoji_code: '🚀', + type: 'rocket', + }); + }); + + it('should stamp emoji_code on the optimistic reaction preview', async () => { + const message = generateMessage({ own_reactions: [] }); + const handleReaction = await renderUseReactionHandlerHook({ message }); + await handleReaction('love'); + const optimisticMessage = updateMessage.mock.calls[0][0]; + expect(optimisticMessage.latest_reactions[0]).toMatchObject({ + emoji_code: '❤️', + type: 'love', }); }); diff --git a/src/components/Message/hooks/useReactionHandler.ts b/src/components/Message/hooks/useReactionHandler.ts index 4953254496..87f9a52ad6 100644 --- a/src/components/Message/hooks/useReactionHandler.ts +++ b/src/components/Message/hooks/useReactionHandler.ts @@ -6,6 +6,11 @@ import { useThreadContext } from '../../Threads'; import { useChannelActionContext } from '../../../context/ChannelActionContext'; import { useChannelStateContext } from '../../../context/ChannelStateContext'; import { useChatContext } from '../../../context/ChatContext'; +import { useComponentContext } from '../../../context/ComponentContext'; +import { + defaultReactionOptions, + getEmojiCodeByReactionType, +} from '../../Reactions/reactionOptions'; import type { LocalMessage, Reaction, ReactionResponse } from 'stream-chat'; @@ -17,6 +22,8 @@ export const useReactionHandler = (message?: LocalMessage) => { const { updateMessage } = useChannelActionContext('useReactionHandler'); const { channel, channelCapabilities } = useChannelStateContext('useReactionHandler'); const { client } = useChatContext('useReactionHandler'); + const { reactionOptions = defaultReactionOptions } = + useComponentContext('useReactionHandler'); const createMessagePreview = useCallback( (add: boolean, reaction: ReactionResponse, message: LocalMessage): LocalMessage => { @@ -69,18 +76,22 @@ export const useReactionHandler = (message?: LocalMessage) => { [client.user, client.userID], ); - const createReactionPreview = (type: string) => ({ + const createReactionPreview = (type: string, emojiCode?: string) => ({ message_id: message?.id, score: 1, type, user: client.user, user_id: client.user?.id, + ...(emojiCode && { emoji_code: emojiCode }), }); const toggleReaction = throttle(async (id: string, type: string, add: boolean) => { if (!message || !channelCapabilities['send-reaction']) return; - const newReaction = createReactionPreview(type) as ReactionResponse; + // Native emoji (e.g. "👍") for this reaction type, sent as `emoji_code` so + // push notifications in mobile SDKs can render the emoji. + const emojiCode = getEmojiCodeByReactionType(reactionOptions, type); + const newReaction = createReactionPreview(type, emojiCode) as ReactionResponse; const tempMessage = createMessagePreview(add, newReaction, message); try { @@ -88,7 +99,10 @@ export const useReactionHandler = (message?: LocalMessage) => { thread?.upsertReplyLocally({ message: tempMessage }); const messageResponse = add - ? await channel.sendReaction(id, { type } as Reaction) + ? await channel.sendReaction(id, { + type, + ...(emojiCode && { emoji_code: emojiCode }), + } as Reaction) : await channel.deleteReaction(id, type); // seems useless as we're expecting WS event to come in and replace this anyway diff --git a/src/components/Reactions/__tests__/reactionOptions.test.ts b/src/components/Reactions/__tests__/reactionOptions.test.ts new file mode 100644 index 0000000000..c764ed4ffc --- /dev/null +++ b/src/components/Reactions/__tests__/reactionOptions.test.ts @@ -0,0 +1,59 @@ +import { + defaultReactionOptions, + emojiToUnicode, + getEmojiCodeByReactionType, + mapEmojiMartData, +} from '../reactionOptions'; + +const noop = () => null; + +describe('getEmojiCodeByReactionType', () => { + it('returns the native emoji for a quick reaction type', () => { + expect(getEmojiCodeByReactionType(defaultReactionOptions, 'like')).toBe('👍'); + expect(getEmojiCodeByReactionType(defaultReactionOptions, 'love')).toBe('❤️'); + expect(getEmojiCodeByReactionType(defaultReactionOptions, 'haha')).toBe('😂'); + }); + + it('returns the native emoji for an extended reaction type', () => { + const reactionOptions = { + extended: { + rocket: { Component: noop, name: 'Rocket', unicode: emojiToUnicode('🚀') }, + }, + quick: {}, + }; + + expect(getEmojiCodeByReactionType(reactionOptions, 'rocket')).toBe('🚀'); + }); + + it('returns undefined for an unknown reaction type', () => { + expect(getEmojiCodeByReactionType(defaultReactionOptions, 'does-not-exist')).toBe( + undefined, + ); + }); + + it('returns undefined when the matched option has no unicode', () => { + const reactionOptions = { quick: { custom: { Component: noop, name: 'Custom' } } }; + + expect(getEmojiCodeByReactionType(reactionOptions, 'custom')).toBe(undefined); + }); + + it('returns undefined for legacy array reaction options (no unicode data)', () => { + const reactionOptions = [{ Component: noop, name: 'Like', type: 'like' }]; + + expect(getEmojiCodeByReactionType(reactionOptions, 'like')).toBe(undefined); + }); +}); + +describe('mapEmojiMartData', () => { + it('stores the unicode code point on each mapped entry', () => { + const mapped = mapEmojiMartData({ + emojis: { + joy: { name: 'Joy', skins: [{ native: '😂' }] }, + }, + }); + + const unicode = emojiToUnicode('😂'); + expect(mapped[unicode].unicode).toBe(unicode); + expect(mapped[unicode].name).toBe('Joy'); + }); +}); diff --git a/src/components/Reactions/reactionOptions.tsx b/src/components/Reactions/reactionOptions.tsx index bc9cffdea1..97b3718a13 100644 --- a/src/components/Reactions/reactionOptions.tsx +++ b/src/components/Reactions/reactionOptions.tsx @@ -48,6 +48,7 @@ export const mapEmojiMartData = ( newMap[unicode] = { Component: () => <>{nativeEmoji}, name: emojiData.name, + unicode, }; } @@ -111,3 +112,25 @@ export const getHasExtendedReactions = (reactionOptions: ReactionOptions) => !Array.isArray(reactionOptions) && typeof reactionOptions.extended !== 'undefined' && Object.keys(reactionOptions.extended).length > 0; + +/** + * Resolves the native emoji character (e.g. "👍") for a given reaction type from + * the configured reaction options. The value is used as the `emoji_code` sent + * with a reaction so that push notifications can render the emoji. + * + * Returns `undefined` when no `unicode` is available for the type (e.g. legacy + * array reaction options or custom options that omit `unicode`). + */ +export const getEmojiCodeByReactionType = ( + reactionOptions: ReactionOptions, + reactionType: string, +): string | undefined => { + // Legacy array reaction options carry no unicode data. + if (Array.isArray(reactionOptions)) return undefined; + + const unicode = + reactionOptions.quick[reactionType]?.unicode ?? + reactionOptions.extended?.[reactionType]?.unicode; + + return unicode ? unicodeToEmoji(unicode) : undefined; +};