Skip to content

Commit cab3ffd

Browse files
chore: adjust MessageReactionsDetail to match design spec (#3065)
## New Exports ### `MessageReactionsDetailLoadingIndicator` New exported component from `stream-chat-react`. Renders a skeleton loading state (3 placeholder items) for the reactions detail user list. Can be used standalone or as a replacement via `ComponentContext.LoadingIndicator`. ```tsx import { MessageReactionsDetailLoadingIndicator } from 'stream-chat-react'; ``` ## Behavioral Changes ### `MessageReactionsDetail` — reaction type filter is now toggleable Clicking an already-selected reaction type in the detail view now **deselects** it (sets the filter to `null`), showing all reactions. Previously, clicking the active type was a no-op. ### `MessageReactionsDetail` — reaction emoji shown per user row When no reaction type filter is active (`selectedReactionType === null`), each user row in the detail list now displays the emoji icon for the reaction type that user sent. This uses `reactionOptions` from `ComponentContext`. ### `MessageReactionsDetail` — uses `ComponentContext.reactionOptions` The component now reads `reactionOptions` from `ComponentContext` to resolve emoji components for individual reaction rows. ### `useFetchReactions` — fetches all reactions when `reactionType` is `null` Previously, the hook short-circuited and skipped fetching when `reactionType` was `null`. It now proceeds with the fetch, passing `undefined` to the handler, which retrieves all reaction types. This is what enables the "show all reactions" view. ### `MessageReactions` — `aria-pressed`/`aria-expanded` on clustered reaction button The clustered reactions list button now includes an `aria-pressed` and `aria-expanded` attribute reflecting whether the reactions detail dialog is open. Improves accessibility for screen readers. ### `useChannelListContext` — warning throttled to single emission The console warning emitted when `useChannelListContext` is called outside its provider is now limited to **one occurrence** (module-level counter). Previously, every call without a provider triggered the warning, which could flood the console. Additionally, the warning is now **suppressed entirely** when `componentName` is not provided.
1 parent 91eba1b commit cab3ffd

File tree

6 files changed

+64
-37
lines changed

6 files changed

+64
-37
lines changed

src/components/Reactions/MessageReactions.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import type {
2525
ReactionsComparator,
2626
ReactionType,
2727
} from './types';
28-
import { DialogAnchor, useDialogOnNearestManager } from '../Dialog';
28+
import { DialogAnchor, useDialogIsOpen, useDialogOnNearestManager } from '../Dialog';
2929

3030
export type MessageReactionsProps = Partial<
3131
Pick<MessageContextValue, 'handleFetchReactions' | 'reactionDetailsSort'>
@@ -103,6 +103,7 @@ const UnMemoizedMessageReactions = (props: MessageReactionsProps) => {
103103
const divRef = useRef<ComponentRef<'div'>>(null);
104104
const dialogId = `message-reactions-detail-${message.id}`;
105105
const { dialog, dialogManager } = useDialogOnNearestManager({ id: dialogId });
106+
const isDialogOpen = useDialogIsOpen(dialogId, dialogManager?.id);
106107

107108
const handleReactionButtonClick = (reactionType: ReactionType | null) => {
108109
if (totalReactionCount > MAX_MESSAGE_REACTIONS_TO_FETCH) {
@@ -149,6 +150,8 @@ const UnMemoizedMessageReactions = (props: MessageReactionsProps) => {
149150
role='figure'
150151
>
151152
<FragmentOrButton
153+
aria-expanded={isDialogOpen}
154+
aria-pressed={isDialogOpen}
152155
buttonIf={visualStyle === 'clustered'}
153156
className='str-chat__message-reactions__list-button'
154157
onClick={() =>
@@ -187,11 +190,7 @@ const UnMemoizedMessageReactions = (props: MessageReactionsProps) => {
187190
<li className='str-chat__message-reactions__list-item str-chat__message-reactions__list-item--more'>
188191
<button
189192
className='str-chat__message-reactions__list-item-button'
190-
onClick={() =>
191-
handleReactionButtonClick(
192-
existingReactions.at(-1)?.reactionType ?? null,
193-
)
194-
}
193+
onClick={() => handleReactionButtonClick(null)}
195194
>
196195
<span className='str-chat__message-reactions__overflow-count'>
197196
+{totalReactionCount - cappedExistingReactions.reactionCountToDisplay}

src/components/Reactions/MessageReactionsDetail.tsx

Lines changed: 42 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
useTranslationContext,
1313
} from '../../context';
1414
import type { ReactionSort } from 'stream-chat';
15+
import { defaultReactionOptions } from './reactionOptions';
1516

1617
export type MessageReactionsDetailProps = Partial<
1718
Pick<MessageContextValue, 'handleFetchReactions' | 'reactionDetailsSort'>
@@ -27,6 +28,21 @@ export type MessageReactionsDetailProps = Partial<
2728

2829
const defaultReactionDetailsSort = { created_at: -1 } as const;
2930

31+
export const MessageReactionsDetailLoadingIndicator = () => {
32+
const elements = useMemo(
33+
() =>
34+
Array.from({ length: 3 }, (_, index) => (
35+
<div className='str-chat__message-reactions-detail__skeleton-item' key={index}>
36+
<div className='str-chat__message-reactions-detail__skeleton-avatar' />
37+
<div className='str-chat__message-reactions-detail__skeleton-line' />
38+
</div>
39+
)),
40+
[],
41+
);
42+
43+
return <>{elements}</>;
44+
};
45+
3046
export function MessageReactionsDetail({
3147
handleFetchReactions,
3248
onSelectedReactionTypeChange,
@@ -37,7 +53,11 @@ export function MessageReactionsDetail({
3753
totalReactionCount,
3854
}: MessageReactionsDetailProps) {
3955
const { client } = useChatContext();
40-
const { Avatar = DefaultAvatar } = useComponentContext(MessageReactionsDetail.name);
56+
const {
57+
Avatar = DefaultAvatar,
58+
LoadingIndicator = MessageReactionsDetailLoadingIndicator,
59+
reactionOptions = defaultReactionOptions,
60+
} = useComponentContext(MessageReactionsDetail.name);
4161
const { t } = useTranslationContext();
4262

4363
const {
@@ -73,7 +93,7 @@ export function MessageReactionsDetail({
7393
return (
7494
<div
7595
className='str-chat__message-reactions-detail'
76-
data-testid='reactions-list-modal'
96+
data-testid='message-reactions-detail'
7797
>
7898
{typeof totalReactionCount === 'number' && (
7999
<div className='str-chat__message-reactions-detail__total-count'>
@@ -92,7 +112,11 @@ export function MessageReactionsDetail({
92112
<button
93113
aria-pressed={reactionType === selectedReactionType}
94114
className='str-chat__message-reactions-detail__reaction-type-list-item-button'
95-
onClick={() => onSelectedReactionTypeChange?.(reactionType)}
115+
onClick={() =>
116+
onSelectedReactionTypeChange?.(
117+
selectedReactionType === reactionType ? null : reactionType,
118+
)
119+
}
96120
>
97121
<span className='str-chat__message-reactions-detail__reaction-type-list-item-icon'>
98122
<EmojiComponent />
@@ -115,30 +139,20 @@ export function MessageReactionsDetail({
115139
className='str-chat__message-reactions-detail__user-list'
116140
data-testid='all-reacting-users'
117141
>
118-
{areReactionsLoading && (
119-
<>
120-
<div className='str-chat__message-reactions-detail__skeleton-item'>
121-
<div className='str-chat__message-reactions-detail__skeleton-avatar' />
122-
<div className='str-chat__message-reactions-detail__skeleton-line' />
123-
</div>
124-
<div className='str-chat__message-reactions-detail__skeleton-item'>
125-
<div className='str-chat__message-reactions-detail__skeleton-avatar' />
126-
<div className='str-chat__message-reactions-detail__skeleton-line' />
127-
</div>
128-
<div className='str-chat__message-reactions-detail__skeleton-item'>
129-
<div className='str-chat__message-reactions-detail__skeleton-avatar' />
130-
<div className='str-chat__message-reactions-detail__skeleton-line' />
131-
</div>
132-
</>
133-
)}
142+
{areReactionsLoading && <LoadingIndicator />}
134143
{!areReactionsLoading && (
135144
<>
136-
{reactionDetailsWithLegacyFallback.map(({ user }) => {
145+
{reactionDetailsWithLegacyFallback.map(({ type, user }) => {
137146
const belongsToCurrentUser = client.user?.id === user?.id;
147+
const EmojiComponent = Array.isArray(reactionOptions)
148+
? undefined
149+
: (reactionOptions.quick[type]?.Component ??
150+
reactionOptions.extended?.[type]?.Component);
151+
138152
return (
139153
<div
140154
className='str-chat__message-reactions-detail__user-list-item'
141-
key={user?.id}
155+
key={`${user?.id}-${type}`}
142156
>
143157
<Avatar
144158
className='str-chat__avatar--with-border'
@@ -154,20 +168,22 @@ export function MessageReactionsDetail({
154168
>
155169
{belongsToCurrentUser ? t('You') : user?.name || user?.id}
156170
</span>
157-
{belongsToCurrentUser && selectedReactionType && (
171+
{belongsToCurrentUser && (
158172
<button
159173
className='str-chat__message-reactions-detail__user-list-item-button'
160174
data-testid='remove-reaction-button'
161-
onClick={(e) => {
162-
contextHandleReaction(selectedReactionType, e).then(() => {
163-
refetch();
164-
});
175+
onClick={async (e) => {
176+
await contextHandleReaction(type, e);
177+
refetch();
165178
}}
166179
>
167180
{t('Tap to remove')}
168181
</button>
169182
)}
170183
</div>
184+
<span className='str-chat__message-reactions-detail__user-list-item-icon'>
185+
{EmojiComponent && !selectedReactionType && <EmojiComponent />}
186+
</span>
171187
</div>
172188
);
173189
})}

src/components/Reactions/ReactionSelectorWithButton.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ElementRef } from 'react';
1+
import type { ComponentRef } from 'react';
22
import React, { useRef } from 'react';
33
import { ReactionSelector as DefaultReactionSelector } from './ReactionSelector';
44
import { DialogAnchor, useDialogIsOpen, useDialogOnNearestManager } from '../Dialog';
@@ -27,7 +27,7 @@ export const ReactionSelectorWithButton = ({
2727
const { isMyMessage, message, threadList } = useMessageContext('MessageOptions');
2828
const { ReactionSelector = DefaultReactionSelector } =
2929
useComponentContext('MessageOptions');
30-
const buttonRef = useRef<ElementRef<'button'>>(null);
30+
const buttonRef = useRef<ComponentRef<'button'>>(null);
3131
const dialogId = DefaultReactionSelector.getDialogId({
3232
messageId: message.id,
3333
threadList,

src/components/Reactions/__tests__/MessageReactionsDetail.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ describe('MessageReactionsDetail', () => {
104104
reactions,
105105
});
106106

107-
expect(getByTestId('reactions-list-modal')).toBeInTheDocument();
107+
expect(getByTestId('message-reactions-detail')).toBeInTheDocument();
108108
const results = await axe(container);
109109
expect(results).toHaveNoViolations();
110110
});

src/components/Reactions/hooks/useFetchReactions.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export function useFetchReactions(options: FetchReactionsOptions) {
2727
const [refetchNonce, setRefetchNonce] = useState<number | null>(null);
2828

2929
useEffect(() => {
30-
if (!shouldFetch || !reactionType) {
30+
if (!shouldFetch) {
3131
return;
3232
}
3333

@@ -36,7 +36,7 @@ export function useFetchReactions(options: FetchReactionsOptions) {
3636
(async () => {
3737
try {
3838
setIsLoading(true);
39-
const reactions = await handleFetchReactions(reactionType, sort);
39+
const reactions = await handleFetchReactions(reactionType ?? undefined, sort);
4040

4141
if (!cancel) {
4242
setReactions(reactions);

src/components/Reactions/styling/MessageReactionsDetail.scss

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,10 +132,21 @@
132132
gap: var(--spacing-xs);
133133
padding-inline: calc(var(--spacing-sm) + var(--spacing-xxs));
134134

135+
.str-chat__message-reactions-detail__user-list-item-icon {
136+
font-size: 14px;
137+
line-height: 20px;
138+
font-family: system-ui;
139+
font-style: normal;
140+
letter-spacing: 0;
141+
display: flex;
142+
}
143+
135144
.str-chat__message-reactions-detail__user-list-item-info {
136145
display: flex;
137146
flex-direction: column;
138147
gap: var(--spacing-xxxs);
148+
flex-grow: 1;
149+
min-width: 0;
139150

140151
.str-chat__message-reactions-detail__user-list-item-username {
141152
color: var(--text-primary);
@@ -149,6 +160,7 @@
149160
color: var(--text-tertiary);
150161
font: var(--str-chat__metadata-default-text);
151162
cursor: pointer;
163+
align-self: flex-start;
152164
}
153165
}
154166
}

0 commit comments

Comments
 (0)