Skip to content

Commit 5d24102

Browse files
authored
refactor: Create MessageMenu component and replace it (#1131)
[CLNP-3260](https://sendbird.atlassian.net/browse/CLNP-3260) ### Feature * Created `MessageMenuProvider`, `useMessageMenuContext`, and `MessageMenu` component * Replaced the `MessageItemMenu` to `MessageMenu` only in the **GroupChannel** (I will apply it to the Thread in a different PR) * Migrated the `MobileContextMenu` and `MobileBottomSheet` using the `MessageMenuProvider` #### ChangeLog ```md * MessageMenu * Created `MessageMenuProvider`, `useMessageMenuContext`, and `MessageMenu` for the message menus in GroupChannel. ``` import { MessageMenu, MessageMenuProvider, useMessageMenuContext } from '@sendbird/uikit-react/ui/MessageMenu'; ``` * Replaced the `MessageItemMenu` component to the `MessageMenu` component. * Migrated the `MobileContextMenu` and `MobileBottomSheet` by applying the `MessageMenuProvider`. ```
1 parent ddf3ada commit 5d24102

File tree

26 files changed

+1496
-610
lines changed

26 files changed

+1496
-610
lines changed

rollup.module-exports.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ export default {
198198
'ui/MessageInput/hooks/usePaste': 'src/ui/MessageInput/hooks/usePaste/index.ts',
199199
'ui/MessageItemMenu': 'src/ui/MessageItemMenu/index.tsx',
200200
'ui/MessageItemReactionMenu': 'src/ui/MessageItemReactionMenu/index.tsx',
201+
'ui/MessageMenu': 'src/ui/MessageMenu/index.tsx',
201202
'ui/MessageSearchFileItem': 'src/ui/MessageSearchFileItem/index.tsx',
202203
'ui/MessageSearchItem': 'src/ui/MessageSearchItem/index.tsx',
203204
'ui/MessageStatus': 'src/ui/MessageStatus/index.tsx',

src/hooks/useElementObserver.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/* global MutationCallback */
2+
/* global MutationObserverInit */
3+
// To prevent Undefined lint errors: https://github.com/standard/standard/issues/1159#issuecomment-403003627
4+
5+
import { useEffect, useState } from 'react';
6+
7+
function useElementObserver(selector: string, targetElement?: HTMLElement | HTMLElement[] | null): boolean {
8+
const [isElementMounted, setIsElementMounted] = useState<boolean>(false);
9+
10+
useEffect(() => {
11+
const targetElements = Array.isArray(targetElement) ? targetElement : [targetElement];
12+
13+
const updateElementState = () => {
14+
const elements = targetElements?.map(target => target?.querySelector(selector));
15+
setIsElementMounted(elements?.some(element => !!element) ?? false);
16+
};
17+
updateElementState();
18+
19+
const observerCallback: MutationCallback = (mutations) => {
20+
mutations.forEach((mutation) => {
21+
if (mutation.addedNodes.length || mutation.removedNodes.length) {
22+
Array.from(mutation.addedNodes).forEach((node) => {
23+
if (node.nodeType === Node.ELEMENT_NODE && (node as Element).matches(selector)) {
24+
setIsElementMounted(true);
25+
}
26+
});
27+
28+
Array.from(mutation.removedNodes).forEach((node) => {
29+
if (node.nodeType === Node.ELEMENT_NODE && (node as Element).matches(selector)) {
30+
setIsElementMounted(false);
31+
}
32+
});
33+
}
34+
});
35+
};
36+
37+
const observer = new MutationObserver(observerCallback);
38+
const observerOptions: MutationObserverInit = {
39+
childList: true, // Observe addition and removal of child nodes
40+
subtree: true, // Observe the entire subtree
41+
};
42+
43+
targetElements?.forEach(target => {
44+
if (target) observer.observe(target, observerOptions);
45+
});
46+
47+
return () => {
48+
observer.disconnect();
49+
};
50+
}, [selector, targetElement]);
51+
52+
return isElementMounted;
53+
}
54+
55+
export default useElementObserver;

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,6 @@ export default function ParentMessageInfo({
324324
userId={userId}
325325
emojiContainer={emojiContainer}
326326
toggleReaction={toggleReaction}
327-
setSupposedHover={setSupposedHover}
328327
/>
329328
)}
330329
{showRemove && (

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

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,6 @@ export default function ThreadListItemContent({
206206
userId={userId}
207207
emojiContainer={emojiContainer}
208208
toggleReaction={toggleReaction}
209-
setSupposedHover={setSupposedHover}
210209
/>
211210
)}
212211
</div>
@@ -357,7 +356,6 @@ export default function ThreadListItemContent({
357356
userId={userId}
358357
emojiContainer={emojiContainer}
359358
toggleReaction={toggleReaction}
360-
setSupposedHover={setSupposedHover}
361359
/>
362360
)}
363361
<MessageItemMenu

src/ui/ContextMenu/EmojiListItems.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ import { createPortal } from 'react-dom';
33

44
import SortByRow from '../SortByRow';
55
import { Nullable } from '../../types';
6+
import { EMOJI_MENU_ROOT_ID, MENU_OBSERVING_CLASS_NAME } from '.';
67

78
const defaultParentRect = { x: 0, y: 0, left: 0, top: 0, height: 0 };
89
type SpaceFromTrigger = { x: number, y: number };
910
type ReactionStyle = { left: number, top: number };
1011
export interface EmojiListItemsProps {
12+
id?: string;
1113
closeDropdown: () => void;
1214
children: ReactNode;
1315
parentRef: RefObject<HTMLDivElement>;
@@ -16,6 +18,7 @@ export interface EmojiListItemsProps {
1618
}
1719

1820
export const EmojiListItems = ({
21+
id,
1922
children,
2023
parentRef,
2124
parentContainRef,
@@ -86,11 +89,11 @@ export const EmojiListItems = ({
8689
}
8790
}, []);
8891

89-
const rootElement = document.getElementById('sendbird-emoji-list-portal');
92+
const rootElement = document.getElementById(EMOJI_MENU_ROOT_ID);
9093
if (rootElement) {
9194
return (
9295
createPortal(
93-
<>
96+
<div className={MENU_OBSERVING_CLASS_NAME} id={id}>
9497
<div className="sendbird-dropdown__menu-backdrop" />
9598
<ul
9699
className="sendbird-dropdown__reaction-bar"
@@ -112,7 +115,7 @@ export const EmojiListItems = ({
112115
{children}
113116
</SortByRow>
114117
</ul>
115-
</>,
118+
</div>,
116119
rootElement,
117120
)
118121
);

src/ui/ContextMenu/MenuItems.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import React, { ReactElement } from 'react';
22
import { createPortal } from 'react-dom';
3+
import { classnames } from '../../utils/utils';
4+
import { MENU_OBSERVING_CLASS_NAME } from '.';
35

46
interface MenuItemsProps {
7+
id?: string;
58
className?: string;
69
testID?: string;
710
style?: Record<string, string>;
@@ -107,11 +110,11 @@ export default class MenuItems extends React.Component<MenuItemsProps, MenuItems
107110
return <></>;
108111

109112
const { menuStyle } = this.state;
110-
const { children, style, className = '', testID } = this.props;
113+
const { children, style, className = '', testID, id } = this.props;
111114
return (
112115
createPortal(
113116
(
114-
<div className={className} data-testid={testID}>
117+
<div className={classnames(MENU_OBSERVING_CLASS_NAME, className)} data-testid={testID} id={id}>
115118
<div className="sendbird-dropdown__menu-backdrop" />
116119
<ul
117120
className={`${className} sendbird-dropdown__menu`}

src/ui/ContextMenu/index.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import Label, { LabelTypography, LabelColors } from '../Label';
1111

1212
const ENTER_KEY = 13;
1313

14+
// # useElementObserve
15+
export const MENU_OBSERVING_CLASS_NAME = 'sendbird-observing-message-menu';
16+
export const getObservingId = (txt: string | number) => `m_${txt}`;
17+
1418
export const MenuItems = _MenuItems;
1519
export const EmojiListItems = _EmojiListItems;
1620

@@ -60,15 +64,14 @@ export const MenuItem = ({
6064
);
6165
};
6266

67+
export const MENU_ROOT_ID = 'sendbird-dropdown-portal';
6368
export const MenuRoot = (): ReactElement => (
64-
<div
65-
id="sendbird-dropdown-portal"
66-
className="sendbird-dropdown-portal"
67-
/>
69+
<div id={MENU_ROOT_ID} className={MENU_ROOT_ID} />
6870
);
6971

7072
// For the test environment
71-
export const EmojiReactionListRoot = (): ReactElement => <div id="sendbird-emoji-list-portal" />;
73+
export const EMOJI_MENU_ROOT_ID = 'sendbird-emoji-list-portal';
74+
export const EmojiReactionListRoot = (): ReactElement => <div id={EMOJI_MENU_ROOT_ID} />;
7275

7376
type MenuDisplayingFunc = () => void;
7477
export interface ContextMenuProps {

src/ui/MessageContent/__tests__/__snapshots__/MessageContent.spec.js.snap

Lines changed: 16 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -77,32 +77,26 @@ exports[`ui/MessageContent should do a snapshot test of the MessageContent DOM 1
7777
class="sendbird-message-content-menu incoming"
7878
>
7979
<div
80-
class="sendbird-message-content-menu__normal-menu sendbird-message-item-menu"
80+
class="sendbird-message-menu sendbird-message-content-menu__normal-menu"
8181
>
82-
<div
83-
class="sendbird-context-menu"
84-
style="display: inline;"
82+
<button
83+
class=" sendbird-iconbutton "
84+
style="height: 32px; width: 32px;"
85+
type="button"
8586
>
86-
<button
87-
class="sendbird-message-item-menu__trigger sendbird-iconbutton "
88-
style="height: 32px; width: 32px;"
89-
type="button"
87+
<span
88+
class="sendbird-iconbutton__inner"
9089
>
91-
<span
92-
class="sendbird-iconbutton__inner"
90+
<div
91+
class=" sendbird-icon sendbird-icon-more sendbird-icon-color--content-inverse"
92+
role="button"
93+
style="width: 24px; min-width: 24px; height: 24px; min-height: 24px;"
94+
tabindex="0"
9395
>
94-
<div
95-
class="sendbird-message-item-menu__trigger__icon sendbird-icon sendbird-icon-more sendbird-icon-color--content-inverse"
96-
data-testid="sendbird-message-item-menu__trigger__icon"
97-
role="button"
98-
style="width: 24px; min-width: 24px; height: 24px; min-height: 24px;"
99-
tabindex="0"
100-
>
101-
<test-file-stub />
102-
</div>
103-
</span>
104-
</button>
105-
</div>
96+
<test-file-stub />
97+
</div>
98+
</span>
99+
</button>
106100
</div>
107101
</div>
108102
</div>

src/ui/MessageContent/index.tsx

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import format from 'date-fns/format';
33
import './index.scss';
44

55
import MessageStatus from '../MessageStatus';
6-
import { MessageMenu, MessageMenuProps } from '../MessageItemMenu';
6+
import { MessageMenu, type MessageMenuProps } from '../MessageMenu';
77
import { MessageEmojiMenu, MessageEmojiMenuProps } from '../MessageItemReactionMenu';
88
import Label, { LabelColors, LabelTypography } from '../Label';
99
import EmojiReactions, { EmojiReactionsProps } from '../EmojiReactions';
@@ -46,6 +46,8 @@ import MessageFeedbackModal from '../MessageFeedbackModal';
4646
import { SbFeedbackStatus } from './types';
4747
import MessageFeedbackFailedModal from '../MessageFeedbackFailedModal';
4848
import { MobileBottomSheetProps } from '../MobileMenu/types';
49+
import useElementObserver from '../../hooks/useElementObserver';
50+
import { EMOJI_MENU_ROOT_ID, getObservingId, MENU_OBSERVING_CLASS_NAME, MENU_ROOT_ID } from '../ContextMenu';
4951

5052
export interface MessageContentProps {
5153
className?: string | Array<string>;
@@ -131,15 +133,21 @@ export default function MessageContent(props: MessageContentProps): ReactElement
131133
const { config, eventHandlers } = useSendbirdStateContext();
132134
const { logger } = config;
133135
const onPressUserProfileHandler = eventHandlers?.reaction?.onPressUserProfile;
134-
const contentRef = useRef(null);
136+
const contentRef = useRef<HTMLDivElement>();
135137
const timestampRef = useRef<HTMLDivElement>();
136138
const threadRepliesRef = useRef<HTMLDivElement>();
137139
const feedbackButtonsRef = useRef<HTMLDivElement>();
138140
const { isMobile } = useMediaQueryContext();
139141
const [showMenu, setShowMenu] = useState(false);
140142

141143
const [mouseHover, setMouseHover] = useState(false);
142-
const [supposedHover, setSupposedHover] = useState(false);
144+
const isMenuMounted = useElementObserver(
145+
`#${getObservingId(message.messageId)}.${MENU_OBSERVING_CLASS_NAME}`,
146+
[
147+
document.getElementById(MENU_ROOT_ID),
148+
document.getElementById(EMOJI_MENU_ROOT_ID),
149+
],
150+
);
143151
// Feedback states
144152
const [showFeedbackOptionsMenu, setShowFeedbackOptionsMenu] = useState(false);
145153
const [showFeedbackModal, setShowFeedbackModal] = useState(false);
@@ -169,7 +177,7 @@ export default function MessageContent(props: MessageContentProps): ReactElement
169177
const chainTopClassName = chainTop ? 'chain-top' : '';
170178
const isReactionEnabledInChannel = isReactionEnabled && !channel?.isEphemeral;
171179
const isReactionEnabledClassName = isReactionEnabledInChannel ? 'use-reactions' : '';
172-
const supposedHoverClassName = supposedHover ? 'sendbird-mouse-hover' : '';
180+
const hoveredMenuClassName = isMenuMounted ? 'sendbird-mouse-hover' : '';
173181
const useReplying = !!((replyType === 'QUOTE_REPLY' || replyType === 'THREAD')
174182
&& message?.parentMessageId && message?.parentMessage
175183
&& !disableQuoteMessage
@@ -279,18 +287,16 @@ export default function MessageContent(props: MessageContentProps): ReactElement
279287
}
280288
{/* outgoing menu */}
281289
{showOutgoingMenu && (
282-
<div className={classnames('sendbird-message-content-menu', isReactionEnabledClassName, supposedHoverClassName, isByMeClassName)}>
290+
<div className={classnames('sendbird-message-content-menu', isReactionEnabledClassName, hoveredMenuClassName, isByMeClassName)}>
283291
{renderMessageMenu({
284292
channel,
285293
message,
286294
isByMe,
287295
replyType,
288-
disabled,
289296
showEdit,
290297
showRemove,
291298
resendMessage,
292299
setQuoteMessage,
293-
setSupposedHover,
294300
onReplyInThread: ({ message }) => {
295301
if (threadReplySelectType === ThreadReplySelectType.THREAD) {
296302
onReplyInThread?.({ message });
@@ -306,7 +312,6 @@ export default function MessageContent(props: MessageContentProps): ReactElement
306312
userId,
307313
emojiContainer,
308314
toggleReaction,
309-
setSupposedHover,
310315
})
311316
)}
312317
</div>
@@ -367,7 +372,7 @@ export default function MessageContent(props: MessageContentProps): ReactElement
367372
className={classnames(
368373
'sendbird-message-content__middle__body-container__created-at',
369374
'left',
370-
supposedHoverClassName,
375+
hoveredMenuClassName,
371376
uiContainerType,
372377
)}
373378
ref={timestampRef}
@@ -423,7 +428,7 @@ export default function MessageContent(props: MessageContentProps): ReactElement
423428
className={classnames(
424429
'sendbird-message-content__middle__body-container__created-at',
425430
'right',
426-
supposedHoverClassName,
431+
hoveredMenuClassName,
427432
uiContainerType,
428433
)}
429434
type={LabelTypography.CAPTION_3}
@@ -519,15 +524,14 @@ export default function MessageContent(props: MessageContentProps): ReactElement
519524
className={classnames('sendbird-message-content__right', chainTopClassName, isReactionEnabledClassName, useReplyingClassName)}
520525
data-testid="sendbird-message-content__right"
521526
>
522-
<div className={classnames('sendbird-message-content-menu', chainTopClassName, supposedHoverClassName, isByMeClassName)}>
527+
<div className={classnames('sendbird-message-content-menu', chainTopClassName, hoveredMenuClassName, isByMeClassName)}>
523528
{isReactionEnabledInChannel && (
524529
renderEmojiMenu({
525530
className: 'sendbird-message-content-menu__reaction-menu',
526531
message,
527532
userId,
528533
emojiContainer,
529534
toggleReaction,
530-
setSupposedHover,
531535
})
532536
)}
533537
{renderMessageMenu({
@@ -536,11 +540,9 @@ export default function MessageContent(props: MessageContentProps): ReactElement
536540
message,
537541
isByMe,
538542
replyType,
539-
disabled,
540543
showRemove,
541544
resendMessage,
542545
setQuoteMessage,
543-
setSupposedHover,
544546
onReplyInThread: ({ message }) => {
545547
if (threadReplySelectType === ThreadReplySelectType.THREAD) {
546548
onReplyInThread?.({ message });

0 commit comments

Comments
 (0)