Fix context menu being opened when clicking message action bar buttons (#9200)

This commit is contained in:
Šimon Brandner 2022-08-18 09:18:18 +02:00 committed by GitHub
parent e269c6895d
commit 3eecd68175
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 155 additions and 59 deletions

View file

@ -225,35 +225,57 @@ export default class ContextMenu extends React.PureComponent<IProps, IState> {
protected renderMenu(hasBackground = this.props.hasBackground) { protected renderMenu(hasBackground = this.props.hasBackground) {
const position: Partial<Writeable<DOMRect>> = {}; const position: Partial<Writeable<DOMRect>> = {};
const props = this.props; const {
top,
bottom,
left,
right,
bottomAligned,
rightAligned,
menuClassName,
menuHeight,
menuWidth,
menuPaddingLeft,
menuPaddingRight,
menuPaddingBottom,
menuPaddingTop,
zIndex,
children,
focusLock,
managed,
wrapperClassName,
chevronFace: propsChevronFace,
chevronOffset: propsChevronOffset,
...props
} = this.props;
if (props.top) { if (top) {
position.top = props.top; position.top = top;
} else { } else {
position.bottom = props.bottom; position.bottom = bottom;
} }
let chevronFace: ChevronFace; let chevronFace: ChevronFace;
if (props.left) { if (left) {
position.left = props.left; position.left = left;
chevronFace = ChevronFace.Left; chevronFace = ChevronFace.Left;
} else { } else {
position.right = props.right; position.right = right;
chevronFace = ChevronFace.Right; chevronFace = ChevronFace.Right;
} }
const contextMenuRect = this.state.contextMenuElem ? this.state.contextMenuElem.getBoundingClientRect() : null; const contextMenuRect = this.state.contextMenuElem ? this.state.contextMenuElem.getBoundingClientRect() : null;
const chevronOffset: CSSProperties = {}; const chevronOffset: CSSProperties = {};
if (props.chevronFace) { if (propsChevronFace) {
chevronFace = props.chevronFace; chevronFace = propsChevronFace;
} }
const hasChevron = chevronFace && chevronFace !== ChevronFace.None; const hasChevron = chevronFace && chevronFace !== ChevronFace.None;
if (chevronFace === ChevronFace.Top || chevronFace === ChevronFace.Bottom) { if (chevronFace === ChevronFace.Top || chevronFace === ChevronFace.Bottom) {
chevronOffset.left = props.chevronOffset; chevronOffset.left = propsChevronOffset;
} else { } else {
chevronOffset.top = props.chevronOffset; chevronOffset.top = propsChevronOffset;
} }
// If we know the dimensions of the context menu, adjust its position to // If we know the dimensions of the context menu, adjust its position to
@ -262,13 +284,13 @@ export default class ContextMenu extends React.PureComponent<IProps, IState> {
if (contextMenuRect) { if (contextMenuRect) {
if (position.top !== undefined) { if (position.top !== undefined) {
let maxTop = windowHeight - WINDOW_PADDING; let maxTop = windowHeight - WINDOW_PADDING;
if (!this.props.bottomAligned) { if (!bottomAligned) {
maxTop -= contextMenuRect.height; maxTop -= contextMenuRect.height;
} }
position.top = Math.min(position.top, maxTop); position.top = Math.min(position.top, maxTop);
// Adjust the chevron if necessary // Adjust the chevron if necessary
if (chevronOffset.top !== undefined) { if (chevronOffset.top !== undefined) {
chevronOffset.top = props.chevronOffset + props.top - position.top; chevronOffset.top = propsChevronOffset + top - position.top;
} }
} else if (position.bottom !== undefined) { } else if (position.bottom !== undefined) {
position.bottom = Math.min( position.bottom = Math.min(
@ -276,17 +298,17 @@ export default class ContextMenu extends React.PureComponent<IProps, IState> {
windowHeight - contextMenuRect.height - WINDOW_PADDING, windowHeight - contextMenuRect.height - WINDOW_PADDING,
); );
if (chevronOffset.top !== undefined) { if (chevronOffset.top !== undefined) {
chevronOffset.top = props.chevronOffset + position.bottom - props.bottom; chevronOffset.top = propsChevronOffset + position.bottom - bottom;
} }
} }
if (position.left !== undefined) { if (position.left !== undefined) {
let maxLeft = windowWidth - WINDOW_PADDING; let maxLeft = windowWidth - WINDOW_PADDING;
if (!this.props.rightAligned) { if (!rightAligned) {
maxLeft -= contextMenuRect.width; maxLeft -= contextMenuRect.width;
} }
position.left = Math.min(position.left, maxLeft); position.left = Math.min(position.left, maxLeft);
if (chevronOffset.left !== undefined) { if (chevronOffset.left !== undefined) {
chevronOffset.left = props.chevronOffset + props.left - position.left; chevronOffset.left = propsChevronOffset + left - position.left;
} }
} else if (position.right !== undefined) { } else if (position.right !== undefined) {
position.right = Math.min( position.right = Math.min(
@ -294,7 +316,7 @@ export default class ContextMenu extends React.PureComponent<IProps, IState> {
windowWidth - contextMenuRect.width - WINDOW_PADDING, windowWidth - contextMenuRect.width - WINDOW_PADDING,
); );
if (chevronOffset.left !== undefined) { if (chevronOffset.left !== undefined) {
chevronOffset.left = props.chevronOffset + position.right - props.right; chevronOffset.left = propsChevronOffset + position.right - right;
} }
} }
} }
@ -320,36 +342,36 @@ export default class ContextMenu extends React.PureComponent<IProps, IState> {
'mx_ContextualMenu_withChevron_right': chevronFace === ChevronFace.Right, 'mx_ContextualMenu_withChevron_right': chevronFace === ChevronFace.Right,
'mx_ContextualMenu_withChevron_top': chevronFace === ChevronFace.Top, 'mx_ContextualMenu_withChevron_top': chevronFace === ChevronFace.Top,
'mx_ContextualMenu_withChevron_bottom': chevronFace === ChevronFace.Bottom, 'mx_ContextualMenu_withChevron_bottom': chevronFace === ChevronFace.Bottom,
'mx_ContextualMenu_rightAligned': this.props.rightAligned === true, 'mx_ContextualMenu_rightAligned': rightAligned === true,
'mx_ContextualMenu_bottomAligned': this.props.bottomAligned === true, 'mx_ContextualMenu_bottomAligned': bottomAligned === true,
}, this.props.menuClassName); }, menuClassName);
const menuStyle: CSSProperties = {}; const menuStyle: CSSProperties = {};
if (props.menuWidth) { if (menuWidth) {
menuStyle.width = props.menuWidth; menuStyle.width = menuWidth;
} }
if (props.menuHeight) { if (menuHeight) {
menuStyle.height = props.menuHeight; menuStyle.height = menuHeight;
} }
if (!isNaN(Number(props.menuPaddingTop))) { if (!isNaN(Number(menuPaddingTop))) {
menuStyle["paddingTop"] = props.menuPaddingTop; menuStyle["paddingTop"] = menuPaddingTop;
} }
if (!isNaN(Number(props.menuPaddingLeft))) { if (!isNaN(Number(menuPaddingLeft))) {
menuStyle["paddingLeft"] = props.menuPaddingLeft; menuStyle["paddingLeft"] = menuPaddingLeft;
} }
if (!isNaN(Number(props.menuPaddingBottom))) { if (!isNaN(Number(menuPaddingBottom))) {
menuStyle["paddingBottom"] = props.menuPaddingBottom; menuStyle["paddingBottom"] = menuPaddingBottom;
} }
if (!isNaN(Number(props.menuPaddingRight))) { if (!isNaN(Number(menuPaddingRight))) {
menuStyle["paddingRight"] = props.menuPaddingRight; menuStyle["paddingRight"] = menuPaddingRight;
} }
const wrapperStyle = {}; const wrapperStyle = {};
if (!isNaN(Number(props.zIndex))) { if (!isNaN(Number(zIndex))) {
menuStyle["zIndex"] = props.zIndex + 1; menuStyle["zIndex"] = zIndex + 1;
wrapperStyle["zIndex"] = props.zIndex; wrapperStyle["zIndex"] = zIndex;
} }
let background; let background;
@ -366,10 +388,10 @@ export default class ContextMenu extends React.PureComponent<IProps, IState> {
let body = <> let body = <>
{ chevron } { chevron }
{ props.children } { children }
</>; </>;
if (props.focusLock) { if (focusLock) {
body = <FocusLock> body = <FocusLock>
{ body } { body }
</FocusLock>; </FocusLock>;
@ -379,7 +401,7 @@ export default class ContextMenu extends React.PureComponent<IProps, IState> {
<RovingTabIndexProvider handleHomeEnd handleUpDown onKeyDown={this.onKeyDown}> <RovingTabIndexProvider handleHomeEnd handleUpDown onKeyDown={this.onKeyDown}>
{ ({ onKeyDownHandler }) => ( { ({ onKeyDownHandler }) => (
<div <div
className={classNames("mx_ContextualMenu_wrapper", this.props.wrapperClassName)} className={classNames("mx_ContextualMenu_wrapper", wrapperClassName)}
style={{ ...position, ...wrapperStyle }} style={{ ...position, ...wrapperStyle }}
onClick={this.onClick} onClick={this.onClick}
onKeyDown={onKeyDownHandler} onKeyDown={onKeyDownHandler}
@ -390,7 +412,8 @@ export default class ContextMenu extends React.PureComponent<IProps, IState> {
className={menuClasses} className={menuClasses}
style={menuStyle} style={menuStyle}
ref={this.collectContextMenuRect} ref={this.collectContextMenuRect}
role={this.props.managed ? "menu" : undefined} role={managed ? "menu" : undefined}
{...props}
> >
{ body } { body }
</div> </div>

View file

@ -240,7 +240,7 @@ class EmojiPicker extends React.Component<IProps, IState> {
render() { render() {
let heightBefore = 0; let heightBefore = 0;
return ( return (
<div className="mx_EmojiPicker"> <div className="mx_EmojiPicker" data-testid='mx_EmojiPicker'>
<Header categories={this.categories} onAnchorClick={this.scrollToCategory} /> <Header categories={this.categories} onAnchorClick={this.scrollToCategory} />
<Search query={this.state.filter} onChange={this.onChangeFilter} onEnter={this.onEnterFilter} /> <Search query={this.state.filter} onChange={this.onChangeFilter} onEnter={this.onEnterFilter} />
<AutoHideScrollbar <AutoHideScrollbar

View file

@ -134,7 +134,6 @@ class ReactionPicker extends React.Component<IProps, IState> {
isEmojiDisabled={this.isEmojiDisabled} isEmojiDisabled={this.isEmojiDisabled}
selectedEmojis={this.state.selectedEmojis} selectedEmojis={this.state.selectedEmojis}
showQuickReactions={true} showQuickReactions={true}
data-testid='mx_ReactionPicker'
/>; />;
} }
} }

View file

@ -16,7 +16,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { ReactElement, useContext, useEffect } from 'react'; import React, { ReactElement, useCallback, useContext, useEffect } from 'react';
import { EventStatus, MatrixEvent, MatrixEventEvent } from 'matrix-js-sdk/src/models/event'; import { EventStatus, MatrixEvent, MatrixEventEvent } from 'matrix-js-sdk/src/models/event';
import classNames from 'classnames'; import classNames from 'classnames';
import { MsgType, RelationType } from 'matrix-js-sdk/src/@types/event'; import { MsgType, RelationType } from 'matrix-js-sdk/src/@types/event';
@ -88,7 +88,7 @@ const OptionsButton: React.FC<IOptionsButtonProps> = ({
onFocusChange(menuDisplayed); onFocusChange(menuDisplayed);
}, [onFocusChange, menuDisplayed]); }, [onFocusChange, menuDisplayed]);
const onOptionsClick = (e: React.MouseEvent): void => { const onOptionsClick = useCallback((e: React.MouseEvent): void => {
// Don't open the regular browser or our context menu on right-click // Don't open the regular browser or our context menu on right-click
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@ -97,7 +97,7 @@ const OptionsButton: React.FC<IOptionsButtonProps> = ({
// the element that is currently focused is skipped. So we want to call onFocus manually to keep the // the element that is currently focused is skipped. So we want to call onFocus manually to keep the
// position in the page even when someone is clicking around. // position in the page even when someone is clicking around.
onFocus(); onFocus();
}; }, [openMenu, onFocus]);
let contextMenu: ReactElement | null; let contextMenu: ReactElement | null;
if (menuDisplayed) { if (menuDisplayed) {
@ -121,6 +121,7 @@ const OptionsButton: React.FC<IOptionsButtonProps> = ({
className="mx_MessageActionBar_iconButton mx_MessageActionBar_optionsButton" className="mx_MessageActionBar_iconButton mx_MessageActionBar_optionsButton"
title={_t("Options")} title={_t("Options")}
onClick={onOptionsClick} onClick={onOptionsClick}
onContextMenu={onOptionsClick}
isExpanded={menuDisplayed} isExpanded={menuDisplayed}
inputRef={ref} inputRef={ref}
onFocus={onFocus} onFocus={onFocus}
@ -153,17 +154,24 @@ const ReactButton: React.FC<IReactButtonProps> = ({ mxEvent, reactions, onFocusC
</ContextMenu>; </ContextMenu>;
} }
return <React.Fragment> const onClick = useCallback((e: React.MouseEvent) => {
<ContextMenuTooltipButton // Don't open the regular browser or our context menu on right-click
className="mx_MessageActionBar_iconButton" e.preventDefault();
title={_t("React")} e.stopPropagation();
onClick={() => {
openMenu(); openMenu();
// when the context menu is opened directly, e.g. via mouse click, the onFocus handler which tracks // when the context menu is opened directly, e.g. via mouse click, the onFocus handler which tracks
// the element that is currently focused is skipped. So we want to call onFocus manually to keep the // the element that is currently focused is skipped. So we want to call onFocus manually to keep the
// position in the page even when someone is clicking around. // position in the page even when someone is clicking around.
onFocus(); onFocus();
}} }, [openMenu, onFocus]);
return <React.Fragment>
<ContextMenuTooltipButton
className="mx_MessageActionBar_iconButton"
title={_t("React")}
onClick={onClick}
onContextMenu={onClick}
isExpanded={menuDisplayed} isExpanded={menuDisplayed}
inputRef={ref} inputRef={ref}
onFocus={onFocus} onFocus={onFocus}
@ -193,7 +201,11 @@ const ReplyInThreadButton = ({ mxEvent }: IReplyInThreadButton) => {
return null; return null;
} }
const onClick = (): void => { const onClick = (e: React.MouseEvent): void => {
// Don't open the regular browser or our context menu on right-click
e.preventDefault();
e.stopPropagation();
if (firstTimeSeeingThreads) { if (firstTimeSeeingThreads) {
localStorage.setItem("mx_seen_feature_thread", "true"); localStorage.setItem("mx_seen_feature_thread", "true");
} }
@ -245,6 +257,7 @@ const ReplyInThreadButton = ({ mxEvent }: IReplyInThreadButton) => {
: _t("Can't create a thread from an event with an existing relation")} : _t("Can't create a thread from an event with an existing relation")}
onClick={onClick} onClick={onClick}
onContextMenu={onClick}
> >
<ThreadIcon /> <ThreadIcon />
{ firstTimeSeeingThreads && !threadsEnabled && ( { firstTimeSeeingThreads && !threadsEnabled && (
@ -265,10 +278,19 @@ const FavouriteButton = ({ mxEvent }: IFavouriteButtonProp) => {
'mx_MessageActionBar_favouriteButton_fillstar': isFavourite(eventId), 'mx_MessageActionBar_favouriteButton_fillstar': isFavourite(eventId),
}); });
const onClick = useCallback((e: React.MouseEvent) => {
// Don't open the regular browser or our context menu on right-click
e.preventDefault();
e.stopPropagation();
toggleFavourite(eventId);
}, [toggleFavourite, eventId]);
return <RovingAccessibleTooltipButton return <RovingAccessibleTooltipButton
className={classes} className={classes}
title={_t("Favourite")} title={_t("Favourite")}
onClick={() => toggleFavourite(eventId)} onClick={onClick}
onContextMenu={onClick}
data-testid={eventId} data-testid={eventId}
> >
<StarIcon /> <StarIcon />
@ -335,7 +357,11 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
this.props.onFocusChange?.(focused); this.props.onFocusChange?.(focused);
}; };
private onReplyClick = (ev: React.MouseEvent): void => { private onReplyClick = (e: React.MouseEvent): void => {
// Don't open the regular browser or our context menu on right-click
e.preventDefault();
e.stopPropagation();
dis.dispatch({ dis.dispatch({
action: 'reply_to_event', action: 'reply_to_event',
event: this.props.mxEvent, event: this.props.mxEvent,
@ -343,7 +369,11 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
}); });
}; };
private onEditClick = (): void => { private onEditClick = (e: React.MouseEvent): void => {
// Don't open the regular browser or our context menu on right-click
e.preventDefault();
e.stopPropagation();
editEvent(this.props.mxEvent, this.context.timelineRenderingType, this.props.getRelationsForEvent); editEvent(this.props.mxEvent, this.context.timelineRenderingType, this.props.getRelationsForEvent);
}; };
@ -406,6 +436,10 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
} }
private onResendClick = (ev: React.MouseEvent): void => { private onResendClick = (ev: React.MouseEvent): void => {
// Don't open the regular browser or our context menu on right-click
ev.preventDefault();
ev.stopPropagation();
this.runActionOnFailedEv((tarEv) => Resend.resend(tarEv)); this.runActionOnFailedEv((tarEv) => Resend.resend(tarEv));
}; };
@ -423,6 +457,7 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
className="mx_MessageActionBar_iconButton" className="mx_MessageActionBar_iconButton"
title={_t("Edit")} title={_t("Edit")}
onClick={this.onEditClick} onClick={this.onEditClick}
onContextMenu={this.onEditClick}
key="edit" key="edit"
> >
<EditIcon /> <EditIcon />
@ -433,6 +468,7 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
className="mx_MessageActionBar_iconButton" className="mx_MessageActionBar_iconButton"
title={_t("Delete")} title={_t("Delete")}
onClick={this.onCancelClick} onClick={this.onCancelClick}
onContextMenu={this.onCancelClick}
key="cancel" key="cancel"
> >
<TrashcanIcon /> <TrashcanIcon />
@ -453,6 +489,7 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
className="mx_MessageActionBar_iconButton" className="mx_MessageActionBar_iconButton"
title={_t("Retry")} title={_t("Retry")}
onClick={this.onResendClick} onClick={this.onResendClick}
onContextMenu={this.onResendClick}
key="resend" key="resend"
> >
<ResendIcon /> <ResendIcon />
@ -475,6 +512,7 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
className="mx_MessageActionBar_iconButton" className="mx_MessageActionBar_iconButton"
title={_t("Reply")} title={_t("Reply")}
onClick={this.onReplyClick} onClick={this.onReplyClick}
onContextMenu={this.onReplyClick}
key="reply" key="reply"
> >
<ReplyIcon /> <ReplyIcon />

View file

@ -194,6 +194,8 @@ exports[`<SpaceContextMenu /> renders menu correctly 1`] = `
/> />
<div <div
className="mx_ContextualMenu" className="mx_ContextualMenu"
hasBackground={true}
onFinished={[MockFunction]}
role="menu" role="menu"
style={Object {}} style={Object {}}
> >

View file

@ -240,11 +240,11 @@ describe('<MessageActionBar />', () => {
}); });
it('opens message context menu on click', () => { it('opens message context menu on click', () => {
const { findByTestId, queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent }); const { getByTestId, queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
act(() => { act(() => {
fireEvent.click(queryByLabelText('Options')); fireEvent.click(queryByLabelText('Options'));
}); });
expect(findByTestId('mx_MessageContextMenu')).toBeTruthy(); expect(getByTestId('mx_MessageContextMenu')).toBeTruthy();
}); });
}); });
@ -310,11 +310,11 @@ describe('<MessageActionBar />', () => {
}); });
it('opens reaction picker on click', () => { it('opens reaction picker on click', () => {
const { queryByLabelText, findByTestId } = getComponent({ mxEvent: alicesMessageEvent }); const { queryByLabelText, getByTestId } = getComponent({ mxEvent: alicesMessageEvent });
act(() => { act(() => {
fireEvent.click(queryByLabelText('React')); fireEvent.click(queryByLabelText('React'));
}); });
expect(findByTestId('mx_ReactionPicker')).toBeTruthy(); expect(getByTestId('mx_EmojiPicker')).toBeTruthy();
}); });
}); });
@ -565,4 +565,38 @@ describe('<MessageActionBar />', () => {
}); });
}); });
}); });
it.each([
["React"],
["Reply"],
["Reply in thread"],
["Favourite"],
["Edit"],
])("does not show context menu when right-clicking", (buttonLabel: string) => {
// For favourite button
jest.spyOn(SettingsStore, 'getValue').mockReturnValue(true);
const event = new MouseEvent("contextmenu", {
bubbles: true,
cancelable: true,
});
event.stopPropagation = jest.fn();
event.preventDefault = jest.fn();
const { queryByTestId, queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
act(() => {
fireEvent(queryByLabelText(buttonLabel), event);
});
expect(event.stopPropagation).toHaveBeenCalled();
expect(event.preventDefault).toHaveBeenCalled();
expect(queryByTestId("mx_MessageContextMenu")).toBeFalsy();
});
it("does shows context menu when right-clicking options", () => {
const { queryByTestId, queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
act(() => {
fireEvent.contextMenu(queryByLabelText("Options"));
});
expect(queryByTestId("mx_MessageContextMenu")).toBeTruthy();
});
}); });