Skip to content

Commit a7ca956

Browse files
Initial commit
1 parent 17a4cb1 commit a7ca956

25 files changed

Lines changed: 493 additions & 246 deletions

src/components/Avatar/styling/AvatarStack.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,6 @@
4949
background: var(--badge-bg-default);
5050
box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.14);
5151
line-height: 1;
52-
z-index: 1;
52+
position: relative;
5353
}
5454
}

src/components/Dialog/hooks/usePopoverPosition.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ function autoMiddlewareFor(p: PopperLikePlacement) {
2121
return autoPlacement({ alignment });
2222
}
2323

24-
type OffsetOpt =
24+
export type OffsetOpt =
2525
| number
2626
| { mainAxis?: number; crossAxis?: number; alignmentAxis?: number }
2727
| [crossAxis: number, mainAxis: number]; // keep your tuple compat

src/components/Dialog/service/DialogAnchor.tsx

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,31 +4,54 @@ import React, { useEffect, useRef, useState } from 'react';
44
import { FocusScope } from '@react-aria/focus';
55
import { DialogPortalEntry } from './DialogPortal';
66
import { useDialog, useDialogIsOpen } from '../hooks';
7-
import { usePopoverPosition } from '../hooks/usePopoverPosition';
7+
import { type OffsetOpt, usePopoverPosition } from '../hooks/usePopoverPosition';
88
import type { PopperLikePlacement } from '../hooks';
9+
import type { Placement } from '@floating-ui/react';
910

1011
export interface DialogAnchorOptions {
1112
open: boolean;
1213
placement: PopperLikePlacement;
1314
referenceElement: HTMLElement | null;
1415
allowFlip?: boolean;
1516
updateKey?: unknown;
17+
updatePositionOnContentResize?: boolean;
18+
offset?: OffsetOpt;
1619
}
1720

1821
export function useDialogAnchor<T extends HTMLElement>({
1922
allowFlip,
23+
offset,
2024
open,
2125
placement,
2226
referenceElement,
2327
updateKey,
28+
updatePositionOnContentResize = false,
2429
}: DialogAnchorOptions) {
2530
const [popperElement, setPopperElement] = useState<T | null>(null);
26-
const { refs, strategy, update, x, y } = usePopoverPosition({
31+
// keeps track of the first "chosen" placement (after popperElement is set) to avoid popper "jumping" to a different placement when it updates and finds a better fit; resets when popperElement is unset (!open)
32+
const [stabilisedChosenPlacement, setStabilisedChosenPlacement] =
33+
useState<Placement | null>(null);
34+
35+
const {
36+
placement: chosenPlacement,
37+
refs,
38+
strategy,
39+
update,
40+
x,
41+
y,
42+
} = usePopoverPosition({
2743
allowFlip,
2844
freeze: true,
29-
placement,
45+
offset,
46+
placement: stabilisedChosenPlacement ?? placement,
3047
});
3148

49+
if (!stabilisedChosenPlacement && popperElement && placement !== chosenPlacement) {
50+
setStabilisedChosenPlacement(chosenPlacement);
51+
} else if (stabilisedChosenPlacement && !popperElement) {
52+
setStabilisedChosenPlacement(null);
53+
}
54+
3255
// Freeze reference when dialog opens so submenus (e.g. ContextMenu level 2+) stay aligned to the original anchor
3356
const frozenReferenceRef = useRef<HTMLElement | null>(null);
3457
if (open && referenceElement && !frozenReferenceRef.current) {
@@ -57,6 +80,18 @@ export function useDialogAnchor<T extends HTMLElement>({
5780
}
5881
}, [open, placement, popperElement, update, updateKey, effectiveReference]);
5982

83+
useEffect(() => {
84+
if (!popperElement || !updatePositionOnContentResize) return;
85+
86+
const resizeObserver = new ResizeObserver(update);
87+
88+
resizeObserver.observe(popperElement);
89+
90+
return () => {
91+
resizeObserver.disconnect();
92+
};
93+
}, [popperElement, update, updatePositionOnContentResize]);
94+
6095
if (popperElement && !open) {
6196
setPopperElement(null);
6297
}
@@ -85,22 +120,26 @@ export const DialogAnchor = ({
85120
dialogManagerId,
86121
focus = true,
87122
id,
123+
offset,
88124
placement = 'auto',
89125
referenceElement = null,
90126
tabIndex,
91127
trapFocus,
92128
updateKey,
129+
updatePositionOnContentResize,
93130
...restDivProps
94131
}: DialogAnchorProps) => {
95132
const dialog = useDialog({ dialogManagerId, id });
96133
const open = useDialogIsOpen(id, dialogManagerId);
97134

98135
const { setPopperElement, styles } = useDialogAnchor<HTMLDivElement>({
99136
allowFlip,
137+
offset,
100138
open,
101139
placement,
102140
referenceElement,
103141
updateKey,
142+
updatePositionOnContentResize,
104143
});
105144

106145
useEffect(() => {

src/components/Message/MessageSimple.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -215,9 +215,6 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => {
215215
onKeyUp={handleClick}
216216
>
217217
<MessageActions />
218-
<div className='str-chat__message-reactions-host'>
219-
{hasReactions && <ReactionsList reverse />}
220-
</div>
221218
<div className='str-chat__message-bubble'>
222219
{poll && <Poll poll={poll} />}
223220
{message.quoted_message && <QuotedMessage />}
@@ -230,6 +227,9 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => {
230227
<MessageText message={message} renderText={renderText} />
231228
)}
232229
</div>
230+
<div className='str-chat__message-reactions-host'>
231+
{hasReactions && <ReactionsList reverse />}
232+
</div>
233233
<MessageErrorIcon />
234234
{showReplyCountButton && (
235235
<MessageRepliesCountButton

src/components/Message/hooks/useReactionsFetcher.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useChatContext, useTranslationContext } from '../../../context';
2+
import { useStableCallback } from '../../../utils/useStableCallback';
23
import type {
34
LocalMessage,
45
ReactionResponse,
@@ -22,15 +23,15 @@ export function useReactionsFetcher(
2223
const { t } = useTranslationContext('useReactionFetcher');
2324
const { getErrorNotification, notify } = notifications;
2425

25-
return async (reactionType?: ReactionType, sort?: ReactionSort) => {
26+
return useStableCallback(async (reactionType?: ReactionType, sort?: ReactionSort) => {
2627
try {
2728
return await fetchMessageReactions(client, message.id, reactionType, sort);
2829
} catch (e) {
2930
const errorMessage = getErrorNotification?.(message);
3031
notify?.(errorMessage || t('Error fetching reactions'), 'error');
3132
throw e;
3233
}
33-
};
34+
});
3435
}
3536

3637
async function fetchMessageReactions(

src/components/Message/styling/Message.scss

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -393,7 +393,9 @@
393393
align-self: end;
394394
}
395395

396-
&:not(.str-chat__message--with-avatar) .str-chat__avatar {
396+
&:not(.str-chat__message--with-avatar)
397+
.str-chat__avatar:has(~ .str-chat__message-inner) {
398+
// hide only avatars next to message bubble, not the rest of them down the tree
397399
display: none;
398400
}
399401

@@ -411,7 +413,6 @@
411413
.str-chat__message-reactions-host {
412414
display: flex;
413415
grid-area: reactions;
414-
z-index: 1;
415416

416417
&:has(.str-chat__message-reactions--top) {
417418
margin-bottom: calc(var(--spacing-xxs) * -1);

src/components/Reactions/ReactionsList.tsx

Lines changed: 78 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
1-
import React, { type ComponentPropsWithoutRef, useState } from 'react';
1+
import React, {
2+
type ComponentPropsWithoutRef,
3+
type ComponentRef,
4+
useMemo,
5+
useRef,
6+
useState,
7+
} from 'react';
28
import clsx from 'clsx';
39

4-
import type { ReactionsListModalProps } from './ReactionsListModal';
510
import { ReactionsListModal as DefaultReactionsListModal } from './ReactionsListModal';
611
import { useProcessReactions } from './hooks/useProcessReactions';
712
import type { MessageContextValue } from '../../context';
8-
import { useComponentContext, useTranslationContext } from '../../context';
13+
import {
14+
useComponentContext,
15+
useMessageContext,
16+
useTranslationContext,
17+
} from '../../context';
918

1019
import { MAX_MESSAGE_REACTIONS_TO_FETCH } from '../Message/hooks';
1120

@@ -16,6 +25,7 @@ import type {
1625
ReactionsComparator,
1726
ReactionType,
1827
} from './types';
28+
import { DialogAnchor, useDialogOnNearestManager } from '../Dialog';
1929

2030
export type ReactionsListProps = Partial<
2131
Pick<MessageContextValue, 'handleFetchReactions' | 'reactionDetailsSort'>
@@ -53,6 +63,9 @@ export type ReactionsListProps = Partial<
5363
visualStyle?: 'clustered' | 'segmented' | null;
5464
};
5565

66+
/**
67+
* Renders a button if `buttonIf` is true, otherwise renders a fragment. No props but children are passed to fragment, but all props are passed to button if it's rendered.
68+
*/
5669
const FragmentOrButton = ({
5770
buttonIf: renderButton = false,
5871
children,
@@ -77,22 +90,47 @@ const UnMemoizedReactionsList = (props: ReactionsListProps) => {
7790
...rest
7891
} = props;
7992

80-
const { existingReactions, hasReactions, totalReactionCount } =
93+
const { existingReactions, hasReactions, totalReactionCount, uniqueReactionTypeCount } =
8194
useProcessReactions(rest);
8295
const [selectedReactionType, setSelectedReactionType] = useState<ReactionType | null>(
8396
null,
8497
);
8598
const { t } = useTranslationContext('ReactionsList');
8699
const { ReactionsListModal = DefaultReactionsListModal } = useComponentContext();
100+
const { isMyMessage, message } = useMessageContext('ReactionsList');
101+
102+
const divRef = useRef<ComponentRef<'div'>>(null);
103+
const dialogId = `message-reactions-detail-${message.id}`;
104+
const { dialog, dialogManager } = useDialogOnNearestManager({ id: dialogId });
87105

88-
const handleReactionButtonClick = (reactionType: ReactionType) => {
106+
const handleReactionButtonClick = (reactionType: ReactionType | null) => {
89107
if (totalReactionCount > MAX_MESSAGE_REACTIONS_TO_FETCH) {
90108
return;
91109
}
92110

93111
setSelectedReactionType(reactionType);
112+
113+
dialog.open();
94114
};
95115

116+
/**
117+
* In segmented style with top position we show max 4 reactions and a
118+
* count of the rest, so we need to cap the existing reactions to display
119+
* at 4 and calculate the count of the rest.
120+
*/
121+
const cappedExistingReactions = useMemo(() => {
122+
if (visualStyle !== 'segmented' || verticalPosition !== 'top') return null;
123+
124+
const sliced = existingReactions.slice(0, 4);
125+
return {
126+
reactionCountToDisplay: sliced.reduce(
127+
(accumulatedCount, { reactionCount }) => accumulatedCount + reactionCount,
128+
0,
129+
),
130+
reactionsToDisplay: sliced,
131+
};
132+
}, [existingReactions, verticalPosition, visualStyle]);
133+
96134
if (!hasReactions) return null;
97135

98136
return (
@@ -106,17 +144,18 @@ const UnMemoizedReactionsList = (props: ReactionsListProps) => {
106144
[`str-chat__message-reactions--${visualStyle}`]:
107145
typeof visualStyle === 'string',
108146
})}
147+
ref={divRef}
109148
role='figure'
110149
>
111150
<FragmentOrButton
112151
buttonIf={visualStyle === 'clustered'}
113152
className='str-chat__message-reactions__list-button'
114153
onClick={() =>
115-
setSelectedReactionType(existingReactions[0]?.reactionType ?? null)
154+
handleReactionButtonClick(existingReactions[0]?.reactionType ?? null)
116155
}
117156
>
118157
<ul className='str-chat__message-reactions__list'>
119-
{existingReactions.map(
158+
{(cappedExistingReactions?.reactionsToDisplay ?? existingReactions).map(
120159
({ EmojiComponent, reactionCount, reactionType }) =>
121160
EmojiComponent && (
122161
<li
@@ -128,12 +167,12 @@ const UnMemoizedReactionsList = (props: ReactionsListProps) => {
128167
className='str-chat__message-reactions__list-item-button'
129168
onClick={() => handleReactionButtonClick(reactionType)}
130169
>
131-
<span className='str-chat__message-reactions__item-icon'>
170+
<span className='str-chat__message-reactions__list-item-icon'>
132171
<EmojiComponent />
133172
</span>
134-
{visualStyle === 'segmented' && (
173+
{visualStyle === 'segmented' && reactionCount > 1 && (
135174
<span
136-
className='str-chat__message-reactions__item-count'
175+
className='str-chat__message-reactions__list-item-count'
137176
data-testclass='message-reactions-item-count'
138177
>
139178
{reactionCount}
@@ -143,6 +182,22 @@ const UnMemoizedReactionsList = (props: ReactionsListProps) => {
143182
</li>
144183
),
145184
)}
185+
{uniqueReactionTypeCount > 4 && cappedExistingReactions && (
186+
<li className='str-chat__message-reactions__list-item str-chat__message-reactions__list-item--more'>
187+
<button
188+
className='str-chat__message-reactions__list-item-button'
189+
onClick={() =>
190+
handleReactionButtonClick(
191+
existingReactions.at(-1)?.reactionType ?? null,
192+
)
193+
}
194+
>
195+
<span className='str-chat__message-reactions__overflow-count'>
196+
+{totalReactionCount - cappedExistingReactions.reactionCountToDisplay}
197+
</span>
198+
</button>
199+
</li>
200+
)}
146201
</ul>
147202
{visualStyle === 'clustered' && (
148203
<span className='str-chat__message-reactions__total-count'>
@@ -151,19 +206,25 @@ const UnMemoizedReactionsList = (props: ReactionsListProps) => {
151206
)}
152207
</FragmentOrButton>
153208
</div>
154-
{selectedReactionType !== null && (
209+
210+
<DialogAnchor
211+
dialogManagerId={dialogManager?.id}
212+
id={dialogId}
213+
offset={8}
214+
placement={isMyMessage() ? 'bottom-end' : 'bottom-start'}
215+
referenceElement={divRef.current}
216+
trapFocus
217+
updatePositionOnContentResize
218+
>
155219
<ReactionsListModal
156220
handleFetchReactions={handleFetchReactions}
157-
onClose={() => setSelectedReactionType(null)}
158-
onSelectedReactionTypeChange={
159-
setSelectedReactionType as ReactionsListModalProps['onSelectedReactionTypeChange']
160-
}
161-
open={selectedReactionType !== null}
221+
onSelectedReactionTypeChange={setSelectedReactionType}
162222
reactions={existingReactions}
163223
selectedReactionType={selectedReactionType}
164224
sortReactionDetails={sortReactionDetails}
225+
totalReactionCount={totalReactionCount}
165226
/>
166-
)}
227+
</DialogAnchor>
167228
</>
168229
);
169230
};

0 commit comments

Comments
 (0)