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
5 changes: 4 additions & 1 deletion src/components/Message/__tests__/Message.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,10 @@ describe('<Message /> 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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -30,12 +32,14 @@ async function renderUseReactionHandlerHook(
params: {
channelContextProps?: Record<string, unknown>;
channelStateContextOverrides?: Record<string, unknown>;
componentContext?: Record<string, unknown>;
message?: LocalMessage | null;
} = {},
) {
const {
channelContextProps = {},
channelStateContextOverrides = {},
componentContext = {},
message = generateMessage(),
} = params;

Expand All @@ -58,7 +62,7 @@ async function renderUseReactionHandlerHook(
})}
>
<ChannelActionProvider value={mockChannelActionContext({ updateMessage })}>
{children}
<ComponentProvider value={componentContext}>{children}</ComponentProvider>
</ChannelActionProvider>
</ChannelStateProvider>
</ChatProvider>
Expand Down Expand Up @@ -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',
});
});

Expand Down
20 changes: 17 additions & 3 deletions src/components/Message/hooks/useReactionHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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 => {
Expand Down Expand Up @@ -69,26 +76,33 @@ 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 {
updateMessage(tempMessage);
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
Expand Down
59 changes: 59 additions & 0 deletions src/components/Reactions/__tests__/reactionOptions.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
23 changes: 23 additions & 0 deletions src/components/Reactions/reactionOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export const mapEmojiMartData = (
newMap[unicode] = {
Component: () => <>{nativeEmoji}</>,
name: emojiData.name,
unicode,
};
}

Expand Down Expand Up @@ -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;
};