Skip to content

Commit b284802

Browse files
feat: introduce extended reaction list to a reactions detail component (#3100)
https://www.figma.com/design/Us73erK1xFNcB5EH3hyq6Y/Chat-SDK-Design-System?node-id=4305-171859&m=dev <img width="316" height="289" alt="Screenshot 2026-04-09 at 4 32 29 PM" src="https://github.com/user-attachments/assets/fe10e4e2-a281-4f38-817f-12f43bafb06a" /> ### New `ComponentContext` slot: `ReactionSelectorExtendedList` A new optional component override has been added to `ComponentContextValue`: ```ts ReactionSelectorExtendedList?: React.ComponentType< ComponentProps<(typeof ReactionSelector)['ExtendedList']> >; ``` This allows consumers to replace the extended reaction list UI (the grid of all available reactions) independently of the `ReactionSelector` component itself. Both `ReactionSelector` and `MessageReactionsDetail` now consume this slot from context, falling back to `ReactionSelector.ExtendedList` when not provided. Pass it via `<Channel ReactionSelectorExtendedList={YourComponent} />`. ### Bug fix: `reactionDetailsSort` prop now forwarded to `MessageReactionsDetail` The `reactionDetailsSort` prop on `MessageReactions` was accepted but silently ignored (the destructured value was unused, suppressed with an eslint-disable comment). It is now correctly forwarded to the `MessageReactionsDetail` child component, meaning custom sort orders for the reaction detail user list will take effect. ### New i18n key: `"Add reaction"` A new translatable string `"Add reaction"` has been added to all 12 locale files. It is used as the `aria-label` on the "add reaction" button inside `MessageReactionsDetail`. Custom translation bundles should include this key. ### Styling changes Three CSS changes in `MessageReactionsDetail.scss`: 1. **Fade overlay height** increased from `var(--size-12)` to `var(--size-16)`. 2. **Fade overlay gradient** changed from a hardcoded `rgba(0,0,0,0.1)` to `var(--background-core-elevation-0)`, making it theme-aware. 3. **Reaction type list horizontal padding** changed from `var(--spacing-xs)` to `var(--spacing-md)`. Consumers who override these styles with selectors targeting `.str-chat__message-reactions-detail` descendants may need to adjust.
1 parent 19a0add commit b284802

28 files changed

Lines changed: 830 additions & 295 deletions

src/components/Dialog/hooks/useDialog.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,17 @@ export const useDialog = ({
2222
}: UseDialogParams) => {
2323
const { dialogManager } = useDialogManager({ dialogManagerId });
2424

25-
useEffect(
26-
() => () => {
25+
useEffect(() => {
26+
dialogManager.cancelPendingRemoval(id);
27+
28+
return () => {
2729
// Since this cleanup can run even if the component is still mounted
2830
// and dialog id is unchanged (e.g. in <StrictMode />), it's safer to
2931
// mark state as unused and only remove it after a timeout, rather than
3032
// to remove it immediately.
3133
dialogManager.markForRemoval(id);
32-
},
33-
[dialogManager, id],
34-
);
34+
};
35+
}, [dialogManager, id]);
3536

3637
return dialogManager.getOrCreate({ closeOnClickOutside, id });
3738
};

src/components/Dialog/service/DialogManager.ts

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -70,12 +70,12 @@ export class DialogManager {
7070
);
7171
}
7272

73-
get(id: DialogId) {
73+
get(id: DialogId): Dialog | undefined {
7474
return this.state.getLatestValue().dialogsById[id];
7575
}
7676

7777
getOrCreate({ closeOnClickOutside, id }: GetOrCreateDialogParams) {
78-
let dialog = this.state.getLatestValue().dialogsById[id];
78+
let dialog = this.get(id);
7979
if (!dialog) {
8080
dialog = {
8181
close: () => {
@@ -97,7 +97,7 @@ export class DialogManager {
9797
};
9898
this.state.next((current) => ({
9999
...current,
100-
dialogsById: { ...current.dialogsById, [id]: dialog },
100+
dialogsById: { ...current.dialogsById, [id]: dialog as Dialog },
101101
}));
102102
}
103103

@@ -106,16 +106,15 @@ export class DialogManager {
106106

107107
if (shouldUpdateDialogSettings) {
108108
if (dialog.removalTimeout) clearTimeout(dialog.removalTimeout);
109-
dialog = {
110-
...dialog,
111-
closeOnClickOutside,
112-
removalTimeout: undefined,
113-
};
114109
this.state.next((current) => ({
115110
...current,
116111
dialogsById: {
117112
...current.dialogsById,
118-
[id]: dialog,
113+
[id]: {
114+
...current.dialogsById[id],
115+
closeOnClickOutside,
116+
removalTimeout: undefined,
117+
},
119118
},
120119
}));
121120
}
@@ -158,9 +157,8 @@ export class DialogManager {
158157
}
159158
}
160159

161-
remove(id: DialogId) {
162-
const state = this.state.getLatestValue();
163-
const dialog = state.dialogsById[id];
160+
remove = (id: DialogId) => {
161+
const dialog = this.get(id);
164162
if (!dialog) return;
165163

166164
if (dialog.removalTimeout) {
@@ -175,15 +173,15 @@ export class DialogManager {
175173
dialogsById: newDialogs,
176174
};
177175
});
178-
}
176+
};
179177

180178
/**
181179
* Marks the dialog state as unused. If the dialog id is referenced again quickly,
182180
* the state will not be removed. Otherwise, the state will be removed after
183181
* a short timeout.
184182
*/
185183
markForRemoval(id: DialogId) {
186-
const dialog = this.state.getLatestValue().dialogsById[id];
184+
const dialog = this.get(id);
187185

188186
if (!dialog) {
189187
return;
@@ -202,4 +200,25 @@ export class DialogManager {
202200
},
203201
}));
204202
}
203+
204+
cancelPendingRemoval(id: DialogId) {
205+
const dialog = this.get(id);
206+
207+
if (!dialog?.removalTimeout) {
208+
return;
209+
}
210+
211+
clearTimeout(dialog.removalTimeout);
212+
213+
this.state.next((current) => ({
214+
...current,
215+
dialogsById: {
216+
...current.dialogsById,
217+
[id]: {
218+
...current.dialogsById[id],
219+
removalTimeout: undefined,
220+
},
221+
},
222+
}));
223+
}
205224
}

src/components/Icons/icons.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,32 @@ export const IconEmoji = createIcon(
495495
</>,
496496
);
497497

498+
export const IconEmojiAdd = createIcon(
499+
'IconEmojiAdd',
500+
<>
501+
<path
502+
d='M1.75 10C1.75 5.44365 5.44365 1.75 10 1.75C10.4142 1.75 10.75 2.08579 10.75 2.5C10.75 2.91421 10.4142 3.25 10 3.25C6.27208 3.25 3.25 6.27208 3.25 10C3.25 13.7279 6.27208 16.75 10 16.75C13.7279 16.75 16.75 13.7279 16.75 10C16.75 9.58579 17.0858 9.25 17.5 9.25C17.9142 9.25 18.25 9.58579 18.25 10C18.25 14.5563 14.5563 18.25 10 18.25C5.44365 18.25 1.75 14.5563 1.75 10Z'
503+
fill='currentColor'
504+
/>
505+
<path
506+
d='M7.1875 9.375C7.70527 9.375 8.125 8.95527 8.125 8.4375C8.125 7.91973 7.70527 7.5 7.1875 7.5C6.66973 7.5 6.25 7.91973 6.25 8.4375C6.25 8.95527 6.66973 9.375 7.1875 9.375Z'
507+
fill='currentColor'
508+
/>
509+
<path
510+
d='M12.8125 9.375C13.3303 9.375 13.75 8.95527 13.75 8.4375C13.75 7.91973 13.3303 7.5 12.8125 7.5C12.2947 7.5 11.875 7.91973 11.875 8.4375C11.875 8.95527 12.2947 9.375 12.8125 9.375Z'
511+
fill='currentColor'
512+
/>
513+
<path
514+
d='M12.4756 11.499C12.683 11.1407 13.1425 11.0182 13.501 11.2256C13.8593 11.433 13.9818 11.8925 13.7744 12.251C13.0125 13.568 11.6947 14.5 10 14.5C8.30531 14.5 6.98748 13.568 6.22559 12.251C6.01825 11.8925 6.14067 11.433 6.49902 11.2256C6.85749 11.0182 7.31695 11.1407 7.52441 11.499C8.05942 12.424 8.91824 13 10 13C11.0818 13 11.9406 12.424 12.4756 11.499Z'
515+
fill='currentColor'
516+
/>
517+
<path
518+
d='M15.083 6.87524V4.91626H13.125C12.7108 4.91626 12.375 4.58047 12.375 4.16626C12.3752 3.7522 12.7109 3.41626 13.125 3.41626H15.083V1.45825C15.083 1.04415 15.4189 0.708427 15.833 0.708252C16.2472 0.708252 16.583 1.04404 16.583 1.45825V3.41626H18.542C18.9559 3.41644 19.2918 3.7523 19.292 4.16626C19.292 4.58036 18.9561 4.91608 18.542 4.91626H16.583V6.87524C16.5828 7.28931 16.2471 7.62524 15.833 7.62524C15.4191 7.62507 15.0832 7.2892 15.083 6.87524Z'
519+
fill='currentColor'
520+
/>
521+
</>,
522+
);
523+
498524
// was: IconExclamation
499525
export const IconExclamationMarkFill = createIcon(
500526
'IconExclamationMarkFill',

src/components/Message/styling/Message.scss

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -280,9 +280,7 @@
280280
var(--str-chat__message-reactions-host-offset-x) * -1
281281
);
282282

283-
&:has(.str-chat__message-reactions--flipped-horizontally) {
284-
margin-inline-end: var(--str-chat__message-reactions-host-offset-x);
285-
}
283+
margin-inline-end: var(--str-chat__message-reactions-host-offset-x);
286284
}
287285

288286
.str-chat__message-reactions.str-chat__message-reactions--segmented.str-chat__message-reactions--bottom
@@ -325,9 +323,7 @@
325323
&:has(.str-chat__message-reactions--top) {
326324
padding-inline-end: calc(var(--str-chat__message-reactions-host-offset-x) * -1);
327325

328-
&:has(.str-chat__message-reactions--flipped-horizontally) {
329-
margin-inline-start: var(--str-chat__message-reactions-host-offset-x);
330-
}
326+
margin-inline-start: var(--str-chat__message-reactions-host-offset-x);
331327
}
332328

333329
.str-chat__message-reactions.str-chat__message-reactions--segmented.str-chat__message-reactions--bottom

src/components/Reactions/MessageReactions.tsx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,6 @@ const UnMemoizedMessageReactions = (props: MessageReactionsProps) => {
6666
capLimit: { clustered: capLimitClustered = 5, segmented: capLimitSegmented = 4 } = {},
6767
flipHorizontalPosition = false,
6868
handleFetchReactions,
69-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
7069
reactionDetailsSort,
7170
verticalPosition = 'top',
7271
visualStyle = 'clustered',
@@ -89,7 +88,9 @@ const UnMemoizedMessageReactions = (props: MessageReactionsProps) => {
8988
const { isMyMessage, message } = useMessageContext('MessageReactions');
9089

9190
const divRef = useRef<ComponentRef<'div'>>(null);
92-
const dialogId = `message-reactions-detail-${message.id}`;
91+
const dialogId = DefaultMessageReactionsDetail.getDialogId({
92+
messageId: message.id,
93+
});
9394
const { dialog, dialogManager } = useDialogOnNearestManager({ id: dialogId });
9495
const isDialogOpen = useDialogIsOpen(dialogId, dialogManager?.id);
9596

@@ -158,6 +159,7 @@ const UnMemoizedMessageReactions = (props: MessageReactionsProps) => {
158159
aria-pressed={isDialogOpen}
159160
buttonIf={visualStyle === 'clustered'}
160161
className='str-chat__message-reactions__list-button'
162+
data-testid='message-reactions-list-button'
161163
onClick={() => handleReactionButtonClick(null)}
162164
>
163165
<ul className='str-chat__message-reactions__list'>
@@ -166,14 +168,18 @@ const UnMemoizedMessageReactions = (props: MessageReactionsProps) => {
166168
EmojiComponent && (
167169
<li
168170
className='str-chat__message-reactions__list-item'
171+
data-testid='message-reactions-list-item'
169172
key={reactionType}
170173
>
171174
<FragmentOrButton
172175
buttonIf={visualStyle === 'segmented'}
173176
className='str-chat__message-reactions__list-item-button'
174177
onClick={() => handleReactionButtonClick(reactionType)}
175178
>
176-
<span className='str-chat__message-reactions__list-item-icon'>
179+
<span
180+
className='str-chat__message-reactions__list-item-icon'
181+
data-testid='message-reactions-list-item-icon'
182+
>
177183
<EmojiComponent />
178184
</span>
179185
{visualStyle === 'segmented' && reactionCount > 1 && (
@@ -206,7 +212,10 @@ const UnMemoizedMessageReactions = (props: MessageReactionsProps) => {
206212
)}
207213
</ul>
208214
{visualStyle === 'clustered' && (
209-
<span className='str-chat__message-reactions__total-count'>
215+
<span
216+
className='str-chat__message-reactions__total-count'
217+
data-testid='message-reactions-total-count'
218+
>
210219
{totalReactionCount}
211220
</span>
212221
)}
@@ -225,6 +234,7 @@ const UnMemoizedMessageReactions = (props: MessageReactionsProps) => {
225234
<MessageReactionsDetail
226235
handleFetchReactions={handleFetchReactions}
227236
onSelectedReactionTypeChange={setSelectedReactionType}
237+
reactionDetailsSort={reactionDetailsSort}
228238
reactionGroups={reactionGroups}
229239
reactions={existingReactions}
230240
selectedReactionType={selectedReactionType}

0 commit comments

Comments
 (0)