Skip to content

Commit fb8e99b

Browse files
authored
feat: hide quick message actions on small screens (#3120)
1 parent d0cbaad commit fb8e99b

8 files changed

Lines changed: 233 additions & 14 deletions

File tree

src/components/Dialog/components/ContextMenu.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,7 @@ export type ContextMenuItemProps = ComponentProps<'button'>;
413413
export type ContextMenuItemComponent = ComponentType<ContextMenuItemProps>;
414414

415415
type ContextMenuContextValue = {
416+
anchorReferenceElement?: HTMLElement | null;
416417
closeMenu: () => void;
417418
openSubmenu: (params: ContextMenuOpenSubmenuParams) => void;
418419
returnToParentMenu: () => void;
@@ -464,6 +465,7 @@ type ContextMenuAnchorProps = Partial<
464465
export type ContextMenuProps = ContextMenuBaseProps & ContextMenuAnchorProps;
465466

466467
export type ContextMenuContentProps = ContextMenuBaseProps & {
468+
anchorReferenceElement?: HTMLElement | null;
467469
transitionDirection?: 'forward' | 'backward';
468470
};
469471

@@ -475,6 +477,7 @@ export type ContextMenuContentProps = ContextMenuBaseProps & {
475477
* handling from `ContextMenu`.
476478
*/
477479
export function ContextMenuContent({
480+
anchorReferenceElement,
478481
backLabel = 'Back',
479482
children,
480483
className,
@@ -547,7 +550,9 @@ export function ContextMenuContent({
547550
}, [transitionDirection, menuStack.length]);
548551

549552
return (
550-
<ContextMenuContext.Provider value={{ closeMenu, openSubmenu, returnToParentMenu }}>
553+
<ContextMenuContext.Provider
554+
value={{ anchorReferenceElement, closeMenu, openSubmenu, returnToParentMenu }}
555+
>
551556
<ContextMenuRoot
552557
className={clsx(className, activeMenu.menuClassName)}
553558
data-str-chat-enable-animations={enableAnimations}
@@ -705,6 +710,7 @@ export const ContextMenu = (props: ContextMenuProps) => {
705710

706711
const content = (
707712
<ContextMenuContentComponent
713+
anchorReferenceElement={isAnchored ? referenceElement : undefined}
708714
{...menuProps}
709715
key={`context-menu-content-${contentResetToken}`}
710716
onMenuLevelChange={handleMenuLevelChange}

src/components/Message/styling/Message.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@
8181
--str-chat-message-options-size: calc(3 * var(--str-chat__message-options-button-size));
8282
padding-inline: var(--str-chat__message-composer-padding);
8383

84+
@media (max-width: 767px) {
85+
--str-chat-message-options-size: var(--str-chat__message-options-button-size);
86+
}
87+
8488
.str-chat__message-bubble {
8589
width: min(100%, var(--str-chat__message-max-width));
8690
max-width: var(--str-chat__message-max-width);

src/components/MessageActions/MessageActions.defaults.tsx

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { useMessageComposerController } from '../MessageComposer/hooks/useMessag
3030
import { savePreEditSnapshot } from '../MessageComposer/preEditSnapshot';
3131
import { useNotificationApi } from '../Notifications';
3232
import { useMessageReminder } from '../Message/hooks/useMessageReminder';
33+
import { ReactionSelector as DefaultReactionSelector } from '../Reactions';
3334
import { ReactionSelectorWithButton } from '../Reactions/ReactionSelectorWithButton';
3435
import {
3536
useChatContext,
@@ -40,6 +41,7 @@ import {
4041
import { RemindMeSubmenu, RemindMeSubmenuHeader } from './RemindMeSubmenu';
4142
import {
4243
ContextMenuButton,
44+
DialogAnchor,
4345
useContextMenuContext,
4446
useDialogIsOpen,
4547
useDialogOnNearestManager,
@@ -67,6 +69,61 @@ const getNotificationError = (error: unknown): Error | undefined => {
6769

6870
const DefaultMessageActionComponents = {
6971
dropdown: {
72+
React() {
73+
const { ReactionSelector = DefaultReactionSelector } = useComponentContext();
74+
const { anchorReferenceElement } = useContextMenuContext();
75+
const { isMyMessage, message, threadList } = useMessageContext();
76+
const { t } = useTranslationContext();
77+
const [referenceElement, setReferenceElement] = useState<HTMLElement | null>(null);
78+
const dialogId = `${DefaultReactionSelector.getDialogId({
79+
messageId: message.id,
80+
threadList,
81+
})}-dropdown`;
82+
const { dialog, dialogManager } = useDialogOnNearestManager({
83+
id: dialogId,
84+
});
85+
const dialogIsOpen = useDialogIsOpen(dialogId, dialogManager?.id);
86+
87+
return (
88+
<>
89+
<DialogAnchor
90+
dialogManagerId={dialogManager?.id}
91+
id={dialogId}
92+
offset={8}
93+
placement={isMyMessage() ? 'top-end' : 'top-start'}
94+
referenceElement={referenceElement}
95+
trapFocus
96+
updatePositionOnContentResize
97+
>
98+
<ReactionSelector dialogId={dialogId} />
99+
</DialogAnchor>
100+
<ContextMenuButton
101+
aria-expanded={dialogIsOpen}
102+
aria-label={t('aria/Open Reaction Selector')}
103+
className={clsx(
104+
msgActionsBoxButtonClassName,
105+
'str-chat__message-actions-list-item-button--react',
106+
)}
107+
data-testid='dropdown-react-action'
108+
Icon={IconEmoji}
109+
onClick={(event) => {
110+
if (dialogIsOpen) {
111+
dialog.close();
112+
return;
113+
}
114+
setReferenceElement(
115+
anchorReferenceElement instanceof HTMLElement
116+
? anchorReferenceElement
117+
: event.currentTarget,
118+
);
119+
dialog.open();
120+
}}
121+
>
122+
{t('Add reaction')}
123+
</ContextMenuButton>
124+
</>
125+
);
126+
},
70127
ThreadReply() {
71128
const { closeMenu } = useContextMenuContext();
72129
const { handleOpenThread } = useMessageContext();
@@ -586,13 +643,20 @@ const DefaultMessageActionComponents = {
586643
// eslint-disable-next-line react/display-name
587644
DropdownToggle: forwardRef<HTMLButtonElement>((_, ref) => {
588645
const { t } = useTranslationContext();
589-
const { message } = useMessageContext();
646+
const { message, threadList } = useMessageContext();
590647
const dropdownDialogIsOpen = useDialogIsOpen(
591648
MessageActions.getDialogId({ messageId: message.id }),
592649
);
593650
const { dialog } = useDialogOnNearestManager({
594651
id: MessageActions.getDialogId({ messageId: message.id }),
595652
});
653+
const reactionSelectorDialogId = DefaultReactionSelector.getDialogId({
654+
messageId: message.id,
655+
threadList,
656+
});
657+
const { dialog: dropdownReactionSelectorDialog } = useDialogOnNearestManager({
658+
id: `${reactionSelectorDialogId}-dropdown`,
659+
});
596660

597661
return (
598662
<QuickMessageActionsButton
@@ -602,6 +666,9 @@ const DefaultMessageActionComponents = {
602666
className='str-chat__message-actions-box-button'
603667
data-testid='message-actions-toggle-button'
604668
onClick={() => {
669+
// Close dropdown-anchored reaction selectors before toggling actions menu
670+
// to avoid stale selector re-anchoring.
671+
dropdownReactionSelectorDialog?.close();
605672
dialog?.toggle();
606673
}}
607674
ref={ref}
@@ -646,6 +713,11 @@ export const defaultMessageActionSet: MessageActionSetItem[] = [
646713
placement: 'quick',
647714
type: 'react',
648715
},
716+
{
717+
Component: DefaultMessageActionComponents.dropdown.React,
718+
placement: 'dropdown',
719+
type: 'react',
720+
},
649721
{
650722
Component: DefaultMessageActionComponents.dropdown.ThreadReply,
651723
placement: 'dropdown',

src/components/MessageActions/MessageActions.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ export const MessageActions: MessageActionsInterface = ({
8181
messageId: message.id,
8282
threadList,
8383
});
84+
const dropdownReactionSelectorDialogId = `${reactionSelectorDialogId}-dropdown`;
8485
const { dialog, dialogManager } = useDialogOnNearestManager({
8586
id: messageActionsDialogId,
8687
});
@@ -92,6 +93,10 @@ export const MessageActions: MessageActionsInterface = ({
9293
reactionSelectorDialogId,
9394
dialogManager?.id,
9495
);
96+
const dropdownReactionSelectorDialogIsOpen = useDialogIsOpen(
97+
dropdownReactionSelectorDialogId,
98+
dialogManager?.id,
99+
);
95100

96101
// do not render anything if total action count is zero
97102
if (dropdownActionSet.length + quickActionSet.length === 0) {
@@ -102,7 +107,9 @@ export const MessageActions: MessageActionsInterface = ({
102107
<div
103108
className={clsx('str-chat__message-options', {
104109
'str-chat__message-options--active':
105-
messageActionsDialogIsOpen || reactionSelectorDialogIsOpen,
110+
messageActionsDialogIsOpen ||
111+
reactionSelectorDialogIsOpen ||
112+
dropdownReactionSelectorDialogIsOpen,
106113
})}
107114
>
108115
{quickDropdownToggleAction && dropdownActionSet.length > 0 && (
@@ -112,6 +119,8 @@ export const MessageActions: MessageActionsInterface = ({
112119
<ContextMenuComponent
113120
backLabel={t('Back')}
114121
className={clsx('str-chat__message-actions-box', {
122+
'str-chat__message-actions-box--hidden':
123+
dropdownReactionSelectorDialogIsOpen,
115124
'str-chat__message-actions-box--open': messageActionsDialogIsOpen,
116125
})}
117126
dialogManagerId={dialogManager?.id}

src/components/MessageActions/__tests__/MessageActions.test.tsx

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ const MESSAGE_ACTIONS_HOST_TEST_ID = 'message-actions-host';
4949
const dialogOverlayTestId = 'str-chat__dialog-overlay';
5050
const threadActionTestId = 'thread-action';
5151
const reactionActionTestId = 'message-reaction-action';
52+
const dropdownReactionActionTestId = 'dropdown-react-action';
5253
const reactionSelectorTestId = 'reaction-selector';
5354

5455
const defaultMessageContextValue = {
@@ -557,6 +558,22 @@ describe('<MessageActions />', () => {
557558

558559
expect(queryByTestId(reactionActionTestId)).not.toBeInTheDocument();
559560
});
561+
562+
it('should display reaction action at the top of dropdown list', async () => {
563+
const { container } = await renderMessageActions({
564+
channelStateOpts: {
565+
channelCapabilities: { 'send-reaction': true },
566+
},
567+
});
568+
await toggleOpenMessageActions();
569+
570+
const contextMenuButtons = container.querySelectorAll(
571+
'.str-chat__context-menu__button',
572+
);
573+
574+
expect(contextMenuButtons[0]).toHaveTextContent('Add reaction');
575+
expect(screen.getByTestId(dropdownReactionActionTestId)).toBeInTheDocument();
576+
});
560577
});
561578

562579
describe('Reaction selector', () => {
@@ -634,6 +651,75 @@ describe('<MessageActions />', () => {
634651

635652
expect(actionsHost).toHaveClass('str-chat__message-options--active');
636653
});
654+
655+
it('should render ReactionSelector when dropdown reaction action is clicked', async () => {
656+
await renderMessageActions({
657+
channelStateOpts: {
658+
channelCapabilities: { 'send-reaction': true },
659+
},
660+
});
661+
await toggleOpenMessageActions();
662+
663+
await act(async () => {
664+
await fireEvent.click(screen.getByTestId(dropdownReactionActionTestId));
665+
});
666+
667+
const reactionSelector = screen.getByTestId(reactionSelectorTestId);
668+
expect(reactionSelector).toBeInTheDocument();
669+
expect(reactionSelector.closest('.str-chat__context-menu')).toBeNull();
670+
const messageActionsMenu = document.querySelector('.str-chat__message-actions-box');
671+
expect(messageActionsMenu).toBeInTheDocument();
672+
expect(messageActionsMenu).toHaveClass('str-chat__message-actions-box--hidden');
673+
});
674+
675+
it('should close ReactionSelector when dropdown reaction action is clicked while selector is open', async () => {
676+
await renderMessageActions({
677+
channelStateOpts: {
678+
channelCapabilities: { 'send-reaction': true },
679+
},
680+
});
681+
await toggleOpenMessageActions();
682+
683+
await act(async () => {
684+
await fireEvent.click(screen.getByTestId(dropdownReactionActionTestId));
685+
});
686+
687+
expect(screen.getByTestId(reactionSelectorTestId)).toBeInTheDocument();
688+
689+
await act(async () => {
690+
await fireEvent.click(screen.getByTestId(dropdownReactionActionTestId));
691+
});
692+
693+
await waitFor(() => {
694+
expect(screen.queryByTestId(reactionSelectorTestId)).not.toBeInTheDocument();
695+
});
696+
697+
const messageActionsMenu = document.querySelector('.str-chat__message-actions-box');
698+
expect(messageActionsMenu).not.toHaveClass('str-chat__message-actions-box--hidden');
699+
});
700+
701+
it('should close ReactionSelector when message actions toggle is clicked while dropdown selector is open', async () => {
702+
await renderMessageActions({
703+
channelStateOpts: {
704+
channelCapabilities: { 'send-reaction': true },
705+
},
706+
});
707+
await toggleOpenMessageActions();
708+
709+
await act(async () => {
710+
await fireEvent.click(screen.getByTestId(dropdownReactionActionTestId));
711+
});
712+
713+
expect(screen.getByTestId(reactionSelectorTestId)).toBeInTheDocument();
714+
715+
await act(async () => {
716+
await fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID));
717+
});
718+
719+
await waitFor(() => {
720+
expect(screen.queryByTestId(reactionSelectorTestId)).not.toBeInTheDocument();
721+
});
722+
});
637723
});
638724

639725
describe('Mark as unread action', () => {

src/components/MessageActions/styling/MessageActions.scss

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
.str-chat__message-actions-box {
44
min-width: 180px;
5+
6+
&.str-chat__message-actions-box--hidden {
7+
visibility: hidden;
8+
pointer-events: none;
9+
}
510
}
611

712
.str-chat__message-options {
@@ -41,3 +46,22 @@
4146
position: relative;
4247
}
4348
}
49+
50+
.str-chat
51+
.str-chat__message-actions-list-item-button.str-chat__message-actions-list-item-button--react {
52+
display: none;
53+
}
54+
55+
@media (max-width: 767px) {
56+
.str-chat .str-chat__message-options {
57+
.str-chat__button.str-chat__message-reactions-button,
58+
.str-chat__button.str-chat__message-reply-in-thread-button {
59+
display: none;
60+
}
61+
}
62+
63+
.str-chat
64+
.str-chat__message-actions-list-item-button.str-chat__message-actions-list-item-button--react {
65+
display: flex;
66+
}
67+
}

0 commit comments

Comments
 (0)