Skip to content

Commit 651d3e7

Browse files
authored
fix: use popper to properly position message actions box (#2241)
### 🎯 Goal Message actions box is currently relatively positioned inside MessageList, which means it sometimes gets clipped by MessageList's boundaries. This PR implements proper positioning for the actions box, preventing it from being clipped in (almost) every case. ### 🛠 Implementation details `stream-chat-react` already uses `react-popper` for tooltips, so it made sense to reuse it for the actions box as well. My initial plan was to render the actions box into a portal and position it using popper, but using a portal turned out to be unnecessary: with the positioning strategies that popper implements, the action box is never clipped by it's parent container. See also: GetStream/stream-chat-css#260 ### 🎨 UI Changes Previously: ![image](https://github.com/GetStream/stream-chat-react/assets/975978/cbeb3f34-7e4d-45bc-94f3-0473b376db76) ![image](https://github.com/GetStream/stream-chat-react/assets/975978/6a71ef77-47b6-42c5-b45c-20c01d114d35) After the fixes: The actions box automatically flips if it's close to the clipping boundary: ![image](https://github.com/GetStream/stream-chat-react/assets/975978/605a99c7-d2dd-4a7a-b039-69918fc444b7) ![image](https://github.com/GetStream/stream-chat-react/assets/975978/df6ab182-8d8a-4992-8323-9ddafbf51742) ### ✅ To-Do - [x] Check with all message types - [x] Check with Angular app - [x] ~~Deal with theming v1~~ - [x] ~~Fix E2E~~
1 parent a95f3b5 commit 651d3e7

File tree

5 files changed

+122
-43
lines changed

5 files changed

+122
-43
lines changed

src/components/MessageActions/MessageActions.tsx

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import React, { PropsWithChildren, useCallback, useEffect, useState } from 'react';
1+
import React, {
2+
ElementRef,
3+
PropsWithChildren,
4+
useCallback,
5+
useEffect,
6+
useRef,
7+
useState,
8+
} from 'react';
29

310
import { MessageActionsBox } from './MessageActionsBox';
411

@@ -9,6 +16,7 @@ import { useChatContext } from '../../context/ChatContext';
916
import { MessageContextValue, useMessageContext } from '../../context/MessageContext';
1017

1118
import type { DefaultStreamChatGenerics, IconProps } from '../../types/types';
19+
import { useMessageActionsBoxPopper } from './hooks';
1220

1321
type MessageContextPropsToPick =
1422
| 'getMessageActions'
@@ -71,6 +79,7 @@ export const MessageActions = <
7179
const handleMute = propHandleMute || contextHandleMute;
7280
const handlePin = propHandlePin || contextHandlePin;
7381
const message = propMessage || contextMessage;
82+
const isMine = mine ? mine() : isMyMessage();
7483

7584
const [actionsBoxOpen, setActionsBoxOpen] = useState(false);
7685

@@ -109,6 +118,14 @@ export const MessageActions = <
109118
};
110119
}, [actionsBoxOpen, hideOptions]);
111120

121+
const actionsBoxButtonRef = useRef<ElementRef<'button'>>(null);
122+
123+
const { attributes, popperElementRef, styles } = useMessageActionsBoxPopper<HTMLDivElement>({
124+
open: actionsBoxOpen,
125+
placement: isMine ? 'top-end' : 'top-start',
126+
referenceElement: actionsBoxButtonRef.current,
127+
});
128+
112129
if (!messageActions.length && !customMessageActions) return null;
113130

114131
return (
@@ -117,22 +134,30 @@ export const MessageActions = <
117134
inline={inline}
118135
setActionsBoxOpen={setActionsBoxOpen}
119136
>
120-
<MessageActionsBox
121-
getMessageActions={getMessageActions}
122-
handleDelete={handleDelete}
123-
handleEdit={setEditingState}
124-
handleFlag={handleFlag}
125-
handleMute={handleMute}
126-
handlePin={handlePin}
127-
isUserMuted={isMuted}
128-
mine={mine ? mine() : isMyMessage()}
129-
open={actionsBoxOpen}
130-
/>
137+
<div
138+
{...attributes.popper}
139+
className='str-chat__message-actions-box-wrapper'
140+
ref={popperElementRef}
141+
style={styles.popper}
142+
>
143+
<MessageActionsBox
144+
getMessageActions={getMessageActions}
145+
handleDelete={handleDelete}
146+
handleEdit={setEditingState}
147+
handleFlag={handleFlag}
148+
handleMute={handleMute}
149+
handlePin={handlePin}
150+
isUserMuted={isMuted}
151+
mine={isMine}
152+
open={actionsBoxOpen}
153+
/>
154+
</div>
131155
<button
132156
aria-expanded={actionsBoxOpen}
133157
aria-haspopup='true'
134158
aria-label='Open Message Actions Menu'
135159
className='str-chat__message-actions-box-button'
160+
ref={actionsBoxButtonRef}
136161
>
137162
<ActionsIcon className='str-chat__message-action-icon' />
138163
</button>

src/components/MessageActions/MessageActionsBox.tsx

Lines changed: 3 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useCallback, useState } from 'react';
1+
import React from 'react';
22
import clsx from 'clsx';
33

44
import { MESSAGE_ACTIONS } from '../Message/utils';
@@ -44,44 +44,21 @@ const UnMemoizedMessageActionsBox = <
4444
handleMute,
4545
handlePin,
4646
isUserMuted,
47-
mine,
4847
open = false,
4948
} = props;
5049

5150
const {
5251
CustomMessageActionsList = DefaultCustomMessageActionsList,
5352
} = useComponentContext<StreamChatGenerics>('MessageActionsBox');
5453
const { setQuotedMessage } = useChannelActionContext<StreamChatGenerics>('MessageActionsBox');
55-
const { customMessageActions, message, messageListRect } = useMessageContext<StreamChatGenerics>(
54+
const { customMessageActions, message } = useMessageContext<StreamChatGenerics>(
5655
'MessageActionsBox',
5756
);
5857

5958
const { t } = useTranslationContext('MessageActionsBox');
6059

61-
const [reverse, setReverse] = useState(false);
62-
6360
const messageActions = getMessageActions();
6461

65-
const checkIfReverse = useCallback(
66-
(containerElement: HTMLDivElement) => {
67-
if (!containerElement) {
68-
setReverse(false);
69-
return;
70-
}
71-
72-
if (open) {
73-
const containerRect = containerElement.getBoundingClientRect();
74-
75-
if (mine) {
76-
setReverse(!!messageListRect && containerRect.left < messageListRect.left);
77-
} else {
78-
setReverse(!!messageListRect && containerRect.right + 5 > messageListRect.right);
79-
}
80-
}
81-
},
82-
[messageListRect, mine, open],
83-
);
84-
8562
const handleQuote = () => {
8663
setQuotedMessage(message);
8764

@@ -96,15 +73,13 @@ const UnMemoizedMessageActionsBox = <
9673
};
9774

9875
const rootClassName = clsx('str-chat__message-actions-box', {
99-
'str-chat__message-actions-box--mine': mine,
10076
'str-chat__message-actions-box--open': open,
101-
'str-chat__message-actions-box--reverse': reverse,
10277
});
10378
const buttonClassName =
10479
'str-chat__message-actions-list-item str-chat__message-actions-list-item-button';
10580

10681
return (
107-
<div className={rootClassName} data-testid='message-actions-box' ref={checkIfReverse}>
82+
<div className={rootClassName} data-testid='message-actions-box'>
10883
<div aria-label='Message Options' className='str-chat__message-actions-list' role='listbox'>
10984
<CustomMessageActionsList customMessageActions={customMessageActions} message={message} />
11085
{messageActions.indexOf(MESSAGE_ACTIONS.quote) > -1 && (

src/components/MessageActions/__tests__/MessageActions.test.js

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,18 @@ describe('<MessageActions /> component', () => {
7272
data-testid="message-actions"
7373
onClick={[Function]}
7474
>
75-
<div />
75+
<div
76+
className="str-chat__message-actions-box-wrapper"
77+
style={
78+
Object {
79+
"left": "0",
80+
"position": "absolute",
81+
"top": "0",
82+
}
83+
}
84+
>
85+
<div />
86+
</div>
7687
<button
7788
aria-expanded={false}
7889
aria-haspopup="true"
@@ -243,7 +254,18 @@ describe('<MessageActions /> component', () => {
243254
data-testid="message-actions"
244255
onClick={[Function]}
245256
>
246-
<div />
257+
<div
258+
className="str-chat__message-actions-box-wrapper"
259+
style={
260+
Object {
261+
"left": "0",
262+
"position": "absolute",
263+
"top": "0",
264+
}
265+
}
266+
>
267+
<div />
268+
</div>
247269
<button
248270
aria-expanded={false}
249271
aria-haspopup="true"
@@ -283,7 +305,18 @@ describe('<MessageActions /> component', () => {
283305
data-testid="message-actions"
284306
onClick={[Function]}
285307
>
286-
<div />
308+
<div
309+
className="str-chat__message-actions-box-wrapper"
310+
style={
311+
Object {
312+
"left": "0",
313+
"position": "absolute",
314+
"top": "0",
315+
}
316+
}
317+
>
318+
<div />
319+
</div>
287320
<button
288321
aria-expanded={false}
289322
aria-haspopup="true"
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './useMessageActionsBoxPopper';
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { Placement } from '@popperjs/core';
2+
import { useEffect, useRef } from 'react';
3+
import { usePopper } from 'react-popper';
4+
5+
export interface MessageActionsBoxPopperOptions {
6+
open: boolean;
7+
placement: Placement;
8+
referenceElement: HTMLElement | null;
9+
}
10+
11+
export function useMessageActionsBoxPopper<T extends HTMLElement>({
12+
open,
13+
placement,
14+
referenceElement,
15+
}: MessageActionsBoxPopperOptions) {
16+
const popperElementRef = useRef<T>(null);
17+
const { attributes, styles, update } = usePopper(referenceElement, popperElementRef.current, {
18+
modifiers: [
19+
{
20+
name: 'eventListeners',
21+
options: {
22+
// It's not safe to update popper position on resize and scroll, since popper's
23+
// reference element might not be visible at the time.
24+
resize: false,
25+
scroll: false,
26+
},
27+
},
28+
],
29+
placement,
30+
});
31+
32+
useEffect(() => {
33+
if (open) {
34+
// Since the popper's reference element might not be (and usually is not) visible
35+
// all the time, it's safer to force popper update before showing it.
36+
update?.();
37+
}
38+
}, [open, update]);
39+
40+
return {
41+
attributes,
42+
popperElementRef,
43+
styles,
44+
};
45+
}

0 commit comments

Comments
 (0)