Skip to content

Commit bfc944b

Browse files
authored
feat: Support emoji category for reaction input (#1206)
# Changelog - Added support for EmojiCategory. You can now filter emojis for different messages when adding Reactions to a message. - Added `filterEmojiCategoryIds` to `GroupChannelProvider` and `ThreadProvider`. - How to Use ``` const filterEmojiCategoryIds = (message: SendableMessage) => { if (message.customType === 'emoji_category_2') return [2]; return [1]; } <GroupChannel filterEmojiCategoryIds={filterEmojiCategoryIds} /> ``` - Note: You need to set your custom EmojiCategory using [Sendbird Platform API](https://sendbird.com/docs/chat/platform-api/v3/message/reactions-and-emojis/reactions-and-emojis-overview) in advance. # Description ## Goal * Customer requested that they be able to show different emojis for different EmojiCategories. * e.g. show different EmojiCategory when a message is sent by a bot, versus when a message is sent by non-bot users. ## Requirements & Spec * Users can leverage a new custom property to customize the reaction menu bar for each message. This property allows filtering specific emoji categories based on conditions, such as the `custom_type` attribute of a message. * Existing reactions from previous versions will remain visible, although they will not appear on the reaction menu bar in the updated version to ensure a seamless transition. ## Approach * ` filterEmojiCategoryIds: (message: SendableMessageType) => EmojiCategory['id'][];` has been introduced in `GroupChannelProvider` and `ThreadProvider`, as well as the relevant UI components. This function: * Can be configured at the provider level and propagated via props to individual UI components. * Accepts a message as a parameter and returns an array of EmojiCategory['id'], dictating which emoji categories are permissible for display on the reaction menu bar. * Operates internally within the reaction bar component to exclude emojis whose categories are not listed in the return array. ## Open Questions * Is `filterEmojiCategoryIds` the optimal approach for user-driven customization of this component, or should we consider introducing a `render~~~()` property for greater flexibility? * Any other tasks to extend public interface?
1 parent 0ef3aa1 commit bfc944b

File tree

13 files changed

+148
-55
lines changed

13 files changed

+148
-55
lines changed

src/modules/GroupChannel/components/Message/MessageView.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { EveryMessage, RenderCustomSeparatorProps, RenderMessageParamsType, ReplyType } from '../../../../types';
22
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
3-
import { EmojiContainer, User } from '@sendbird/chat';
3+
import { type EmojiCategory, EmojiContainer, User } from '@sendbird/chat';
44
import { GroupChannel } from '@sendbird/chat/groupChannel';
55
import type { FileMessage, UserMessage, UserMessageCreateParams, UserMessageUpdateParams } from '@sendbird/chat/message';
66
import format from 'date-fns/format';
@@ -68,6 +68,7 @@ export interface MessageViewProps extends MessageProps {
6868
nicknamesMap: Map<string, string>;
6969

7070
renderUserMentionItem?: (props: { user: User }) => React.ReactElement;
71+
filterEmojiCategoryIds?: (message: SendableMessageType) => EmojiCategory['id'][];
7172
scrollToMessage: (createdAt: number, messageId: number) => void;
7273
toggleReaction: (message: SendableMessageType, emojiKey: string, isReacted: boolean) => void;
7374
setQuoteMessage: React.Dispatch<React.SetStateAction<SendableMessageType | null>>;
@@ -147,6 +148,7 @@ const MessageView = (props: MessageViewProps) => {
147148
renderEditInput,
148149
renderFileViewer,
149150
renderRemoveMessageModal,
151+
filterEmojiCategoryIds,
150152
} = deleteNullish(props);
151153

152154
const { dateLocale, stringSet } = useLocalization();
@@ -285,6 +287,7 @@ const MessageView = (props: MessageViewProps) => {
285287
onQuoteMessageClick: onQuoteMessageClick,
286288
onMessageHeightChange: handleScroll,
287289
onBeforeDownloadFileMessage,
290+
filterEmojiCategoryIds,
288291
})}
289292
{ /* Suggested Replies */ }
290293
{

src/modules/GroupChannel/components/Message/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export const Message = (props: MessageProps): React.ReactElement => {
2525
nicknamesMap,
2626
setQuoteMessage,
2727
renderUserMentionItem,
28+
filterEmojiCategoryIds,
2829
onQuoteMessageClick,
2930
onReplyInThreadClick,
3031
onMessageAnimated,
@@ -66,6 +67,7 @@ export const Message = (props: MessageProps): React.ReactElement => {
6667
threadReplySelectType={threadReplySelectType ?? ThreadReplySelectType.PARENT}
6768
nicknamesMap={nicknamesMap}
6869
renderUserMentionItem={renderUserMentionItem}
70+
filterEmojiCategoryIds={filterEmojiCategoryIds}
6971
scrollToMessage={scrollToMessage}
7072
toggleReaction={toggleReaction}
7173
setQuoteMessage={setQuoteMessage}

src/modules/GroupChannel/context/GroupChannelProvider.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React, { useContext, useEffect, useMemo, useRef, useState } from 'react';
2-
import type { SendbirdError, User } from '@sendbird/chat';
2+
import type { EmojiCategory, SendbirdError, User } from '@sendbird/chat';
33
import {
44
type FileMessage,
55
FileMessageCreateParams,
@@ -58,6 +58,7 @@ interface ContextBaseType extends
5858

5959
// Custom
6060
messageListQueryParams?: MessageListQueryParamsType;
61+
filterEmojiCategoryIds?: (message: SendableMessageType) => EmojiCategory['id'][];
6162

6263
// Handlers
6364
onBeforeSendUserMessage?: OnBeforeHandler<UserMessageCreateParams>;
@@ -136,6 +137,7 @@ export const GroupChannelProvider = (props: GroupChannelProviderProps) => {
136137
onSearchClick,
137138
onQuoteMessageClick,
138139
renderUserMentionItem,
140+
filterEmojiCategoryIds,
139141
} = props;
140142

141143
// Global context
@@ -358,6 +360,7 @@ export const GroupChannelProvider = (props: GroupChannelProviderProps) => {
358360
onQuoteMessageClick,
359361
// ## Custom render
360362
renderUserMentionItem,
363+
filterEmojiCategoryIds,
361364

362365
// Internal Interface
363366
currentChannel,

src/modules/Thread/components/ParentMessageInfo/ParentMessageInfoItem.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export default function ParentMessageInfoItem({
6363
emojiContainer,
6464
nicknamesMap,
6565
toggleReaction,
66+
filterEmojiCategoryIds,
6667
} = useThreadContext();
6768
const { isMobile } = useMediaQueryContext();
6869

@@ -343,6 +344,7 @@ export default function ParentMessageInfoItem({
343344
memberNicknamesMap={nicknamesMap}
344345
toggleReaction={toggleReaction}
345346
onPressUserProfile={onPressUserProfileHandler}
347+
filterEmojiCategoryIds={filterEmojiCategoryIds}
346348
/>
347349
</div>
348350
)}

src/modules/Thread/components/ParentMessageInfo/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export default function ParentMessageInfo({
6161
isMuted,
6262
isChannelFrozen,
6363
onBeforeDownloadFileMessage,
64+
filterEmojiCategoryIds,
6465
} = useThreadContext();
6566
const { isMobile } = useMediaQueryContext();
6667

@@ -336,6 +337,7 @@ export default function ParentMessageInfo({
336337
userId: userId,
337338
emojiContainer: emojiContainer,
338339
toggleReaction: toggleReaction,
340+
filterEmojiCategoryIds,
339341
})
340342
)}
341343
</div>

src/modules/Thread/components/ThreadList/ThreadListItemContent.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ export default function ThreadListItemContent(props: ThreadListItemContentProps)
109109
document.getElementById(EMOJI_MENU_ROOT_ID),
110110
],
111111
);
112-
const { deleteMessage, onBeforeDownloadFileMessage } = useThreadContext();
112+
const { deleteMessage, onBeforeDownloadFileMessage, filterEmojiCategoryIds } = useThreadContext();
113113

114114
const isByMe = (userId === (message as SendableMessageType)?.sender?.userId)
115115
|| ((message as SendableMessageType)?.sendingStatus === 'pending')
@@ -185,6 +185,7 @@ export default function ThreadListItemContent(props: ThreadListItemContentProps)
185185
userId: userId,
186186
emojiContainer: emojiContainer,
187187
toggleReaction: toggleReaction,
188+
filterEmojiCategoryIds,
188189
})}
189190
</>
190191
)}
@@ -260,6 +261,7 @@ export default function ThreadListItemContent(props: ThreadListItemContentProps)
260261
memberNicknamesMap: nicknamesMap,
261262
toggleReaction,
262263
onPressUserProfile: onPressUserProfileHandler,
264+
filterEmojiCategoryIds,
263265
})
264266
}
265267
</div>
@@ -287,6 +289,7 @@ export default function ThreadListItemContent(props: ThreadListItemContentProps)
287289
userId: userId,
288290
emojiContainer: emojiContainer,
289291
toggleReaction: toggleReaction,
292+
filterEmojiCategoryIds,
290293
})
291294
)}
292295
{renderMessageMenu({

src/modules/Thread/context/ThreadProvider.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import React, { useReducer, useMemo, useEffect } from 'react';
2+
import { type EmojiCategory } from '@sendbird/chat';
23
import { GroupChannel } from '@sendbird/chat/groupChannel';
34
import type {
45
BaseMessage, FileMessage,
@@ -48,6 +49,7 @@ export interface ThreadProviderProps extends
4849
onBeforeSendMultipleFilesMessage?: (files: Array<File>, quotedMessage?: SendableMessageType) => MultipleFilesMessageCreateParams;
4950
onBeforeDownloadFileMessage?: OnBeforeDownloadFileMessageType;
5051
isMultipleFilesMessageEnabled?: boolean;
52+
filterEmojiCategoryIds?: (message: SendableMessageType) => EmojiCategory['id'][];
5153
}
5254
export interface ThreadProviderInterface extends ThreadProviderProps, ThreadContextInitialState {
5355
// hooks for fetching threads
@@ -77,6 +79,7 @@ export const ThreadProvider = (props: ThreadProviderProps) => {
7779
onBeforeSendMultipleFilesMessage,
7880
onBeforeDownloadFileMessage,
7981
isMultipleFilesMessageEnabled,
82+
filterEmojiCategoryIds,
8083
} = props;
8184
const propsMessage = props?.message;
8285
const propsParentMessage = getParentMessageFrom(propsMessage);
@@ -265,6 +268,7 @@ export const ThreadProvider = (props: ThreadProviderProps) => {
265268
isChannelFrozen,
266269
currentUserId,
267270
typingMembers,
271+
filterEmojiCategoryIds,
268272
}}
269273
>
270274
{/* UserProfileProvider */}

src/ui/ContextMenu/index.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@
7171
margin: 4px;
7272
}
7373
}
74+
75+
.sendbird-dropdown__reaction-bar__emptyLabel {
76+
width: fit-content;
77+
}
7478
}
7579

7680
.sendbird-dropdown__menu-backdrop {

src/ui/EmojiReactions/ReactionItem.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ import useLongPress from '../../hooks/useLongPress';
1818
import { LocalizationContext } from '../../lib/LocalizationContext';
1919
import useSendbirdStateContext from '../../hooks/useSendbirdStateContext';
2020
import { useMessageContext } from '../../modules/Message/context/MessageProvider';
21+
import { ModalFooter } from '../Modal';
22+
import { ButtonTypes } from '../Button';
23+
import { useGlobalModalContext } from '../../hooks/useModal';
2124

2225
type Props = {
2326
reaction: Reaction;
@@ -27,6 +30,7 @@ type Props = {
2730
emojisMap: Map<string, Emoji>;
2831
channel: Nullable<GroupChannel | OpenChannel>;
2932
message?: SendableMessageType;
33+
isFiltered?: boolean;
3034
};
3135

3236
export default function ReactionItem({
@@ -37,7 +41,9 @@ export default function ReactionItem({
3741
emojisMap,
3842
channel,
3943
message,
44+
isFiltered,
4045
}: Props) {
46+
const { openModal } = useGlobalModalContext();
4147
const store = useSendbirdStateContext();
4248
const { isMobile } = useMediaQueryContext();
4349
const messageStore = useMessageContext();
@@ -49,6 +55,27 @@ export default function ReactionItem({
4955
&& (channel?.isGroupChannel() && !channel.isSuper);
5056

5157
const handleOnClick = () => {
58+
if (isFiltered && !reactedByMe) {
59+
openModal({
60+
modalProps: {
61+
titleText: 'Add reaction failed',
62+
hideFooter: true,
63+
isCloseOnClickOutside: true,
64+
},
65+
childElement: ({ closeModal }) => (
66+
<ModalFooter
67+
type={ButtonTypes.PRIMARY}
68+
submitText={stringSet.BUTTON__OK}
69+
hideCancelButton
70+
onCancel={closeModal}
71+
onSubmit={closeModal}
72+
/>
73+
),
74+
});
75+
76+
return;
77+
}
78+
5279
setEmojiKey('');
5380
toggleReaction?.((message ?? messageStore?.message as UserMessage), reaction.key, reactedByMe);
5481
};

src/ui/EmojiReactions/index.tsx

Lines changed: 65 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import './index.scss';
2-
import React, { ReactElement, useRef, useState } from 'react';
3-
import type { Emoji, EmojiContainer, User } from '@sendbird/chat';
2+
import React, { ReactElement, useMemo, useRef, useState } from 'react';
3+
import type { Emoji, EmojiCategory, EmojiContainer, User } from '@sendbird/chat';
44
import type { Reaction } from '@sendbird/chat/message';
55
import type { GroupChannel } from '@sendbird/chat/groupChannel';
66

@@ -11,7 +11,12 @@ import Icon, { IconTypes, IconColors } from '../Icon';
1111
import ContextMenu, { EmojiListItems } from '../ContextMenu';
1212
import { Nullable, SpaceFromTriggerType } from '../../types';
1313

14-
import { getClassName, getEmojiListAll, getEmojiMapAll, SendableMessageType } from '../../utils';
14+
import {
15+
getClassName,
16+
getEmojiListByCategoryIds,
17+
getEmojiMapAll,
18+
SendableMessageType,
19+
} from '../../utils';
1520
import { ReactedMembersBottomSheet } from '../MobileMenu/ReactedMembersBottomSheet';
1621
import ReactionItem from './ReactionItem';
1722
import { useMediaQueryContext } from '../../lib/MediaQueryContext';
@@ -31,6 +36,7 @@ export interface EmojiReactionsProps {
3136
isByMe?: boolean;
3237
toggleReaction?: (message: SendableMessageType, key: string, byMe: boolean) => void;
3338
onPressUserProfile?: (member: User) => void;
39+
filterEmojiCategoryIds?: (message: SendableMessageType) => EmojiCategory['id'][];
3440
}
3541

3642
const EmojiReactions = ({
@@ -44,6 +50,7 @@ const EmojiReactions = ({
4450
isByMe = false,
4551
toggleReaction,
4652
onPressUserProfile,
53+
filterEmojiCategoryIds,
4754
}: EmojiReactionsProps): ReactElement => {
4855
let showTheReactedMembers = false;
4956
try {
@@ -62,6 +69,9 @@ const EmojiReactions = ({
6269
const [selectedEmojiKey, setSelectedEmojiKey] = useState('');
6370

6471
const emojisMap = getEmojiMapAll(emojiContainer);
72+
const filteredEmojis = useMemo(() => {
73+
return getEmojiListByCategoryIds(emojiContainer, filterEmojiCategoryIds?.(message));
74+
}, [emojiContainer, filterEmojiCategoryIds]);
6575
const showAddReactionBadge = (message.reactions?.length ?? 0) < emojisMap.size;
6676

6777
return (
@@ -81,6 +91,10 @@ const EmojiReactions = ({
8191
emojisMap={emojisMap}
8292
channel={channel}
8393
message={message}
94+
isFiltered={
95+
getEmojiListByCategoryIds(emojiContainer, filterEmojiCategoryIds?.(message))
96+
.every(elem => elem.key !== reaction?.key)
97+
}
8498
/>
8599
);
86100
})
@@ -106,50 +120,54 @@ const EmojiReactions = ({
106120
/>
107121
</ReactionBadge>
108122
)}
109-
menuItems={(closeDropdown: () => void): ReactElement => (
110-
<EmojiListItems
111-
parentRef={addReactionRef}
112-
parentContainRef={addReactionRef}
113-
closeDropdown={closeDropdown}
114-
spaceFromTrigger={spaceFromTrigger}
115-
>
116-
{getEmojiListAll(emojiContainer).map((emoji: Emoji): ReactElement => {
117-
const isReacted: boolean = (message?.reactions
118-
?.find((reaction: Reaction): boolean => reaction.key === emoji.key)?.userIds
119-
?.some((reactorId: string): boolean => reactorId === userId)) || false;
120-
return (
121-
<ReactionButton
122-
key={emoji.key}
123-
width="36px"
124-
height="36px"
125-
selected={isReacted}
126-
onClick={(e): void => {
127-
closeDropdown();
128-
toggleReaction?.(message, emoji.key, isReacted);
129-
e?.stopPropagation();
130-
}}
131-
testID={`ui_emoji_reactions_menu_${emoji.key}`}
132-
>
133-
<ImageRenderer
134-
url={emoji?.url || ''}
135-
width="28px"
136-
height="28px"
137-
placeHolder={({ style }): ReactElement => (
138-
<div style={style}>
139-
<Icon
140-
type={IconTypes.QUESTION}
141-
fillColor={IconColors.ON_BACKGROUND_3}
142-
width="28px"
143-
height="28px"
144-
/>
145-
</div>
146-
)}
147-
/>
148-
</ReactionButton>
149-
);
150-
})}
151-
</EmojiListItems>
152-
)}
123+
menuItems={(closeDropdown: () => void): ReactElement => {
124+
if (filteredEmojis.length === 0) return null;
125+
126+
return (
127+
<EmojiListItems
128+
parentRef={addReactionRef}
129+
parentContainRef={addReactionRef}
130+
closeDropdown={closeDropdown}
131+
spaceFromTrigger={spaceFromTrigger}
132+
>
133+
{getEmojiListByCategoryIds(emojiContainer, filterEmojiCategoryIds?.(message)).map((emoji: Emoji): ReactElement => {
134+
const isReacted: boolean = (message?.reactions
135+
?.find((reaction: Reaction): boolean => reaction.key === emoji.key)?.userIds
136+
?.some((reactorId: string): boolean => reactorId === userId)) || false;
137+
return (
138+
<ReactionButton
139+
key={emoji.key}
140+
width="36px"
141+
height="36px"
142+
selected={isReacted}
143+
onClick={(e): void => {
144+
closeDropdown();
145+
toggleReaction?.(message, emoji.key, isReacted);
146+
e?.stopPropagation();
147+
}}
148+
testID={`ui_emoji_reactions_menu_${emoji.key}`}
149+
>
150+
<ImageRenderer
151+
url={emoji?.url || ''}
152+
width="28px"
153+
height="28px"
154+
placeHolder={({ style }): ReactElement => (
155+
<div style={style}>
156+
<Icon
157+
type={IconTypes.QUESTION}
158+
fillColor={IconColors.ON_BACKGROUND_3}
159+
width="28px"
160+
height="28px"
161+
/>
162+
</div>
163+
)}
164+
/>
165+
</ReactionButton>
166+
);
167+
})}
168+
</EmojiListItems>
169+
);
170+
}}
153171
/>
154172
)}
155173
{(isMobile && showAddReactionBadge) && (

0 commit comments

Comments
 (0)