Add message right click context menu v2 (#5672)

* migrate the message context menu to IconizedContextMenu

Signed-off-by: Michael Weimann <mail@michael-weimann.eu>

* migrate the message context menu to IconizedContextMenu

Signed-off-by: Michael Weimann <mail@michael-weimann.eu>

* Added right-click menu

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* add message context menu group keys

Signed-off-by: Michael Weimann <mail@michael-weimann.eu>

* add message context menu icons

Signed-off-by: Michael Weimann <mail@michael-weimann.eu>

* add _MessageContextMenu.scss license header

Signed-off-by: Michael Weimann <mail@michael-weimann.eu>

* use null vars for context menu lists

* Add allowOverridingNativeContextMenus()

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Use allowOverridingNativeContextMenus()

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Fix types

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Fix types

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Remove mistaken line

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Fix styling

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* InputHTMLAttributes -> AllHTMLAttributes

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Convert to TS

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Add some types

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Make onClick optional

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Add rightClick prop

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Add copy button

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* What about upgrading deps after the eslint migration, Simon?

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Add edit button

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* fix

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Add reply button

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Add react button

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Cleanup render()

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Fix comments

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Add save button

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Don't show context menu if editing

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Add special handling for click a timestamp

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Fix double empty line

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Don't show context menu for images

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Cleanup

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Fix order

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Keep action bar shown when right-clicking

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Highlight event tile when right-clicking

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Delint

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Pointless change so that I can re-run the CI

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Remove dowload button

Because we don't use this menu when clicking on images

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Be more clear for non-bools

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Use triggerOnMouse down prop

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Remove a comment

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Remove unused var

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Remove unnecessary import

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Add some missing types

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Add missing type

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Remove unused import

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Add a missing type

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Fix types

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Fix types/naming

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Add missing current

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Remove unused var

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Fix editing and replying

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* i18n

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Fix import

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Support right-click context menu for threads

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Make button order match `MessageActionBar`

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Fix missing permalink button

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Remove useless part of if statement

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Some small refactoring for consistency

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Some more refactoring

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Fix `editEvent()` call

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Make editing polls work

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Fix collapse reply chain button

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Fix timelineRenderingType

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Fix reply button

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Hide right-click context menu behind a labs flag

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Add missing return type

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Make `contextMene` optional

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Move `renderContextMenu()`

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Simplify `renderContextMenu()`

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Improve `aboveLeftOf` typing

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Use `InputHTMLAttributes`

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Disable message right-click context menu in browser (for now)

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Give permalink button more props

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

Co-authored-by: Michael Weimann <mail@michael-weimann.eu>
pull/21833/head
Šimon Brandner 2022-04-15 16:22:59 +02:00 committed by GitHub
parent 77b0addbc7
commit d162e021e1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 408 additions and 123 deletions

View File

@ -90,6 +90,22 @@ limitations under the License.
mask-image: url('$(res)/img/element-icons/room/pin.svg');
}
.mx_MessageContextMenu_iconCopy::before {
mask-image: url($copy-button-url);
}
.mx_MessageContextMenu_iconEdit::before {
mask-image: url('$(res)/img/element-icons/room/message-bar/edit.svg');
}
.mx_MessageContextMenu_iconReply::before {
mask-image: url('$(res)/img/element-icons/room/message-bar/reply.svg');
}
.mx_MessageContextMenu_iconReact::before {
mask-image: url('$(res)/img/element-icons/room/message-bar/emoji.svg');
}
.mx_MessageContextMenu_iconViewInRoom::before {
mask-image: url('$(res)/img/element-icons/view-in-room.svg');
}

View File

@ -145,6 +145,13 @@ export default abstract class BasePlatform {
return false;
}
/**
* Returns true if platform allows overriding native context menus
*/
public allowOverridingNativeContextMenus(): boolean {
return false;
}
/**
* Returns true if the platform supports displaying
* notifications, otherwise false.

View File

@ -429,7 +429,7 @@ export type AboveLeftOf = IPosition & {
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the left of elementRect,
// and either above or below: wherever there is more space (maybe this should be aboveOrBelowLeftOf?)
export const aboveLeftOf = (
elementRect: DOMRect,
elementRect: Pick<DOMRect, "right" | "top" | "bottom">,
chevronFace = ChevronFace.None,
vPadding = 0,
): AboveLeftOf => {

View File

@ -1,6 +1,7 @@
/*
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
Copyright 2021 - 2022 Šimon Brandner <simon.bra.ag@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -15,7 +16,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ReactElement } from 'react';
import React, { createRef } from 'react';
import { EventStatus, MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { EventType, RelationType } from "matrix-js-sdk/src/@types/event";
import { Relations } from 'matrix-js-sdk/src/models/relations';
@ -30,20 +31,25 @@ import Modal from '../../../Modal';
import Resend from '../../../Resend';
import SettingsStore from '../../../settings/SettingsStore';
import { isUrlPermitted } from '../../../HtmlUtils';
import { isContentActionable } from '../../../utils/EventUtils';
import { canEditContent, editEvent, isContentActionable } from '../../../utils/EventUtils';
import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from './IconizedContextMenu';
import { ReadPinsEventId } from "../right_panel/types";
import { Action } from "../../../dispatcher/actions";
import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
import { ButtonEvent } from '../elements/AccessibleButton';
import { copyPlaintext } from '../../../utils/strings';
import ContextMenu, { toRightOf } from '../../structures/ContextMenu';
import ReactionPicker from '../emojipicker/ReactionPicker';
import ViewSource from '../../structures/ViewSource';
import { createRedactEventDialog } from '../dialogs/ConfirmRedactDialog';
import ShareDialog from '../dialogs/ShareDialog';
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { ChevronFace, IPosition } from '../../structures/ContextMenu';
import { IPosition, ChevronFace } from '../../structures/ContextMenu';
import RoomContext, { TimelineRenderingType } from '../../../contexts/RoomContext';
import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
import EndPollDialog from '../dialogs/EndPollDialog';
import { isPollEnded } from '../messages/MPollBody';
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { GetRelationsForEvent } from "../rooms/EventTile";
import { OpenForwardDialogPayload } from "../../../dispatcher/payloads/OpenForwardDialogPayload";
import { OpenReportEventDialogPayload } from "../../../dispatcher/payloads/OpenReportEventDialogPayload";
import { createMapSiteLink } from '../../../utils/location';
@ -65,42 +71,54 @@ interface IProps extends IPosition {
chevronFace: ChevronFace;
/* the MatrixEvent associated with the context menu */
mxEvent: MatrixEvent;
/* an optional EventTileOps implementation that can be used to unhide preview widgets */
// An optional EventTileOps implementation that can be used to unhide preview widgets
eventTileOps?: IEventTileOps;
// Callback called when the menu is dismissed
permalinkCreator?: RoomPermalinkCreator;
/* an optional function to be called when the user clicks collapse thread, if not provided hide button */
collapseReplyChain?(): void;
/* callback called when the menu is dismissed */
onFinished(): void;
/* if the menu is inside a dialog, we sometimes need to close that dialog after click (forwarding) */
// If the menu is inside a dialog, we sometimes need to close that dialog after click (forwarding)
onCloseDialog?(): void;
getRelationsForEvent?: (
eventId: string,
relationType: string,
eventType: string
) => Relations;
// True if the menu is being used as a right click menu
rightClick?: boolean;
// The Relations model from the JS SDK for reactions to `mxEvent`
reactions?: Relations;
// A permalink to the event
showPermalink?: boolean;
getRelationsForEvent?: GetRelationsForEvent;
}
interface IState {
canRedact: boolean;
canPin: boolean;
reactionPickerDisplayed: boolean;
}
export default class MessageContextMenu extends React.Component<IProps, IState> {
static contextType = RoomContext;
public context!: React.ContextType<typeof RoomContext>;
state = {
canRedact: false,
canPin: false,
};
private reactButtonRef = createRef<any>(); // XXX Ref to a functional component
componentDidMount() {
constructor(props: IProps) {
super(props);
this.state = {
canRedact: false,
canPin: false,
reactionPickerDisplayed: false,
};
}
public componentDidMount() {
MatrixClientPeg.get().on(RoomMemberEvent.PowerLevel, this.checkPermissions);
this.checkPermissions();
}
componentWillUnmount() {
public componentWillUnmount(): void {
const cli = MatrixClientPeg.get();
if (cli) {
cli.removeListener(RoomMemberEvent.PowerLevel, this.checkPermissions);
@ -233,11 +251,45 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
this.closeMenu();
};
private onCopyPermalinkClick = (e: ButtonEvent): void => {
e.preventDefault(); // So that we don't open the permalink
copyPlaintext(this.getPermalink());
this.closeMenu();
};
private onCollapseReplyChainClick = (): void => {
this.props.collapseReplyChain();
this.closeMenu();
};
private onCopyClick = (): void => {
copyPlaintext(this.getSelectedText());
this.closeMenu();
};
private onEditClick = (): void => {
editEvent(this.props.mxEvent, this.context.timelineRenderingType, this.props.getRelationsForEvent);
this.closeMenu();
};
private onReplyClick = (): void => {
dis.dispatch({
action: 'reply_to_event',
event: this.props.mxEvent,
context: this.context.timelineRenderingType,
});
this.closeMenu();
};
private onReactClick = (): void => {
this.setState({ reactionPickerDisplayed: true });
};
private onCloseReactionPicker = (): void => {
this.setState({ reactionPickerDisplayed: false });
this.closeMenu();
};
private onEndPollClick = (): void => {
const matrixClient = MatrixClientPeg.get();
Modal.createTrackedDialog('End Poll', '', EndPollDialog, {
@ -258,11 +310,20 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
});
}
private getSelectedText(): string {
return window.getSelection().toString();
}
private getPermalink(): string {
if (!this.props.permalinkCreator) return;
return this.props.permalinkCreator.forEvent(this.props.mxEvent.getId());
}
private getUnsentReactions(): MatrixEvent[] {
return this.getReactions(e => e.status === EventStatus.NOT_SENT);
}
private viewInRoom = () => {
private viewInRoom = (): void => {
dis.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
event_id: this.props.mxEvent.getId(),
@ -273,12 +334,22 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
this.closeMenu();
};
render() {
public render(): JSX.Element {
const cli = MatrixClientPeg.get();
const me = cli.getUserId();
const mxEvent = this.props.mxEvent;
const { mxEvent, rightClick, showPermalink, eventTileOps, reactions, collapseReplyChain } = this.props;
const eventStatus = mxEvent.status;
const unsentReactionsCount = this.getUnsentReactions().length;
const contentActionable = isContentActionable(mxEvent);
const permalink = this.getPermalink();
// status is SENT before remote-echo, null after
const isSent = !eventStatus || eventStatus === EventStatus.SENT;
const { timelineRenderingType, canReact, canSendMessages } = this.context;
const isThread = (
timelineRenderingType === TimelineRenderingType.Thread ||
timelineRenderingType === TimelineRenderingType.ThreadsList
);
const isThreadRootEvent = isThread && mxEvent?.getThread()?.rootEvent === mxEvent;
let openInMapSiteButton: JSX.Element;
let endPollButton: JSX.Element;
@ -289,21 +360,27 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
let unhidePreviewButton: JSX.Element;
let externalURLButton: JSX.Element;
let quoteButton: JSX.Element;
let collapseReplyChain: JSX.Element;
let redactItemList: JSX.Element;
let reportEventButton: JSX.Element;
let copyButton: JSX.Element;
let editButton: JSX.Element;
let replyButton: JSX.Element;
let reactButton: JSX.Element;
let reactionPicker: JSX.Element;
let quickItemsList: JSX.Element;
let nativeItemsList: JSX.Element;
let permalinkButton: JSX.Element;
let collapseReplyChainButton: JSX.Element;
let viewInRoomButton: JSX.Element;
// status is SENT before remote-echo, null after
const isSent = !eventStatus || eventStatus === EventStatus.SENT;
if (!mxEvent.isRedacted()) {
if (unsentReactionsCount !== 0) {
resendReactionsButton = (
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconResend"
label={_t('Resend %(unsentCount)s reaction(s)', { unsentCount: unsentReactionsCount })}
onClick={this.onResendReactionsClick}
/>
);
}
if (!mxEvent.isRedacted() && unsentReactionsCount !== 0) {
resendReactionsButton = (
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconResend"
label={_t('Resend %(unsentCount)s reaction(s)', { unsentCount: unsentReactionsCount })}
onClick={this.onResendReactionsClick}
/>
);
}
if (isSent && this.state.canRedact) {
@ -335,26 +412,24 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
);
}
if (isContentActionable(mxEvent)) {
if (canForward(mxEvent)) {
forwardButton = (
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconForward"
label={_t("Forward")}
onClick={this.onForwardClick}
/>
);
}
if (contentActionable && canForward(mxEvent)) {
forwardButton = (
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconForward"
label={_t("Forward")}
onClick={this.onForwardClick}
/>
);
}
if (this.state.canPin) {
pinButton = (
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconPin"
label={this.isPinned() ? _t('Unpin') : _t('Pin')}
onClick={this.onPinClick}
/>
);
}
if (contentActionable && this.state.canPin) {
pinButton = (
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconPin"
label={this.isPinned() ? _t('Unpin') : _t('Pin')}
onClick={this.onPinClick}
/>
);
}
let viewSourceButton: JSX.Element;
@ -368,39 +443,38 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
);
}
if (this.props.eventTileOps) {
if (this.props.eventTileOps.isWidgetHidden()) {
unhidePreviewButton = (
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconUnhidePreview"
label={_t("Show preview")}
onClick={this.onUnhidePreviewClick}
/>
);
}
if (eventTileOps?.isWidgetHidden()) {
unhidePreviewButton = (
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconUnhidePreview"
label={_t("Show preview")}
onClick={this.onUnhidePreviewClick}
/>
);
}
let permalink: string | null = null;
let permalinkButton: ReactElement | null = null;
if (this.props.permalinkCreator) {
permalink = this.props.permalinkCreator.forEvent(this.props.mxEvent.getId());
}
permalinkButton = (
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconPermalink"
onClick={this.onPermalinkClick}
label={_t('Share')}
element="a"
{
// XXX: Typescript signature for AccessibleButton doesn't work properly for non-inputs like `a`
...{
href: permalink,
target: "_blank",
rel: "noreferrer noopener",
if (permalink) {
permalinkButton = (
<IconizedContextMenuOption
iconClassName={showPermalink
? "mx_MessageContextMenu_iconCopy"
: "mx_MessageContextMenu_iconPermalink"
}
}
/>
);
onClick={showPermalink ? this.onCopyPermalinkClick : this.onPermalinkClick}
label={showPermalink ? _t('Copy link') : _t('Share')}
element="a"
{
// XXX: Typescript signature for AccessibleButton doesn't work properly for non-inputs like `a`
...{
href: permalink,
target: "_blank",
rel: "noreferrer noopener",
}
}
/>
);
}
if (this.canEndPoll(mxEvent)) {
endPollButton = (
@ -412,7 +486,7 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
);
}
if (this.props.eventTileOps) { // this event is rendered using TextualBody
if (eventTileOps) { // this event is rendered using TextualBody
quoteButton = (
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconQuote"
@ -423,7 +497,8 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
}
// Bridges can provide a 'external_url' to link back to the source.
if (typeof (mxEvent.getContent().external_url) === "string" &&
if (
typeof (mxEvent.getContent().external_url) === "string" &&
isUrlPermitted(mxEvent.getContent().external_url)
) {
externalURLButton = (
@ -444,8 +519,8 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
);
}
if (this.props.collapseReplyChain) {
collapseReplyChain = (
if (collapseReplyChain) {
collapseReplyChainButton = (
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconCollapse"
label={_t("Collapse reply thread")}
@ -454,7 +529,6 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
);
}
let reportEventButton: JSX.Element;
if (mxEvent.getSender() !== me) {
reportEventButton = (
<IconizedContextMenuOption
@ -465,20 +539,79 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
);
}
const { timelineRenderingType } = this.context;
const isThread = (
timelineRenderingType === TimelineRenderingType.Thread ||
timelineRenderingType === TimelineRenderingType.ThreadsList
);
const isThreadRootEvent = isThread && this.props.mxEvent.isThreadRoot;
if (rightClick && this.getSelectedText()) {
copyButton = (
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconCopy"
label={_t("Copy")}
triggerOnMouseDown={true} // We use onMouseDown so that the selection isn't cleared when we click
onClick={this.onCopyClick}
/>
);
}
const commonItemsList = (
<IconizedContextMenuOptionList>
{ isThreadRootEvent && <IconizedContextMenuOption
if (rightClick && canEditContent(mxEvent)) {
editButton = (
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconEdit"
label={_t("Edit")}
onClick={this.onEditClick}
/>
);
}
if (rightClick && contentActionable && canSendMessages) {
replyButton = (
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconReply"
label={_t("Reply")}
onClick={this.onReplyClick}
/>
);
}
if (rightClick && contentActionable && canReact) {
reactButton = (
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconReact"
label={_t("React")}
onClick={this.onReactClick}
inputRef={this.reactButtonRef}
/>
);
}
if (isThreadRootEvent) {
viewInRoomButton = (
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconViewInRoom"
label={_t("View in room")}
onClick={this.viewInRoom}
/> }
/>
);
}
if (copyButton) {
nativeItemsList = (
<IconizedContextMenuOptionList>
{ copyButton }
</IconizedContextMenuOptionList>
);
}
if (editButton || replyButton || reactButton) {
quickItemsList = (
<IconizedContextMenuOptionList>
{ reactButton }
{ replyButton }
{ editButton }
</IconizedContextMenuOptionList>
);
}
const commonItemsList = (
<IconizedContextMenuOptionList>
{ viewInRoomButton }
{ openInMapSiteButton }
{ endPollButton }
{ quoteButton }
@ -490,7 +623,7 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
{ unhidePreviewButton }
{ viewSourceButton }
{ resendReactionsButton }
{ collapseReplyChain }
{ collapseReplyChainButton }
</IconizedContextMenuOptionList>
);
@ -501,15 +634,38 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
</IconizedContextMenuOptionList>
);
}
if (this.state.reactionPickerDisplayed) {
const buttonRect = (this.reactButtonRef.current as HTMLElement)?.getBoundingClientRect();
reactionPicker = (
<ContextMenu
{...toRightOf(buttonRect)}
onFinished={this.closeMenu}
managed={false}
>
<ReactionPicker
mxEvent={mxEvent}
onFinished={this.onCloseReactionPicker}
reactions={reactions}
/>
</ContextMenu>
);
}
return (
<IconizedContextMenu
{...this.props}
className="mx_MessageContextMenu"
compact={true}
>
{ commonItemsList }
{ redactItemList }
</IconizedContextMenu>
<React.Fragment>
<IconizedContextMenu
{...this.props}
className="mx_MessageContextMenu"
compact={true}
>
{ nativeItemsList }
{ quickItemsList }
{ commonItemsList }
{ redactItemList }
</IconizedContextMenu>
{ reactionPicker }
</React.Fragment>
);
}
}

View File

@ -53,6 +53,7 @@ interface IProps extends React.InputHTMLAttributes<Element> {
tabIndex?: number;
disabled?: boolean;
className?: string;
triggerOnMouseDown?: boolean;
onClick(e?: ButtonEvent): void | Promise<void>;
}
@ -78,13 +79,18 @@ export default function AccessibleButton({
className,
onKeyDown,
onKeyUp,
triggerOnMouseDown,
...restProps
}: IProps) {
const newProps: IAccessibleButtonProps = restProps;
if (disabled) {
newProps["aria-disabled"] = true;
} else {
newProps.onClick = onClick;
if (triggerOnMouseDown) {
newProps.onMouseDown = onClick;
} else {
newProps.onClick = onClick;
}
// We need to consume enter onKeyDown and space onKeyUp
// otherwise we are risking also activating other keyboard focusable elements
// that might receive focus as a result of the AccessibleButtonClick action

View File

@ -23,7 +23,7 @@ import Tooltip, { Alignment } from './Tooltip';
interface IProps extends React.ComponentProps<typeof AccessibleButton> {
title: string;
tooltip?: React.ReactNode;
label?: React.ReactNode;
label?: string;
tooltipClassName?: string;
forceHide?: boolean;
yOffset?: number;

View File

@ -38,6 +38,8 @@ import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { E2EState } from "./E2EIcon";
import { toRem } from "../../../utils/units";
import RoomAvatar from "../avatars/RoomAvatar";
import MessageContextMenu, { IEventTileOps } from "../context_menus/MessageContextMenu";
import { aboveLeftOf } from '../../structures/ContextMenu';
import { objectHasDiff } from "../../../utils/objects";
import Tooltip from "../elements/Tooltip";
import EditorStateTransfer from "../../../utils/EditorStateTransfer";
@ -47,6 +49,7 @@ import NotificationBadge from "./NotificationBadge";
import CallEventGrouper from "../../structures/CallEventGrouper";
import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
import { Action } from '../../../dispatcher/actions';
import PlatformPeg from '../../../PlatformPeg';
import MemberAvatar from '../avatars/MemberAvatar';
import SenderProfile from '../messages/SenderProfile';
import MessageTimestamp from '../messages/MessageTimestamp';
@ -96,6 +99,10 @@ export interface IReadReceiptProps {
ts: number;
}
export interface IEventTileType extends React.Component {
getEventTileOps?(): IEventTileOps;
}
interface IProps {
// the MatrixEvent to show
mxEvent: MatrixEvent;
@ -220,6 +227,13 @@ interface IState {
reactions: Relations;
hover: boolean;
// Position of the context menu
contextMenu?: {
position: Pick<DOMRect, "right" | "top" | "bottom">;
showPermalink?: boolean;
};
isQuoteExpanded?: boolean;
thread: Thread;
@ -230,8 +244,7 @@ interface IState {
export class UnwrappedEventTile extends React.Component<IProps, IState> {
private suppressReadReceiptAnimation: boolean;
private isListeningForReceipts: boolean;
// TODO: Types
private tile = React.createRef<unknown>();
private tile = React.createRef<IEventTileType>();
private replyChain = React.createRef<ReplyChain>();
private threadState: ThreadNotificationState;
@ -264,6 +277,8 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {
previouslyRequestedKeys: false,
// The Relations model from the JS SDK for reactions to `mxEvent`
reactions: this.getReactions(),
// Context menu position
contextMenu: null,
hover: false,
@ -898,10 +913,10 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {
private onActionBarFocusChange = (actionBarFocused: boolean) => {
this.setState({ actionBarFocused });
};
// TODO: Types
private getTile: () => any | null = () => this.tile.current;
private getReplyChain = () => this.replyChain.current;
private getTile: () => IEventTileType = () => this.tile.current;
private getReplyChain = (): ReplyChain => this.replyChain.current;
private getReactions = () => {
if (
@ -923,6 +938,44 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {
});
};
private onContextMenu = (ev: React.MouseEvent): void => {
this.showContextMenu(ev);
};
private onTimestampContextMenu = (ev: React.MouseEvent): void => {
this.showContextMenu(ev, true);
};
private showContextMenu(ev: React.MouseEvent, showPermalink?: boolean): void {
if (!SettingsStore.getValue("feature_message_right_click_context_menu")) return;
// There is no way to copy non-PNG images into clipboard, so we can't
// have our own handling for copying images, so we leave it to the
// Electron layer (webcontents-handler.ts)
if (ev.target instanceof HTMLImageElement) return;
if (!PlatformPeg.get().allowOverridingNativeContextMenus()) return;
if (this.props.editState) return;
ev.preventDefault();
ev.stopPropagation();
this.setState({
contextMenu: {
position: {
right: ev.clientX,
top: ev.clientY,
bottom: ev.clientY,
},
showPermalink: showPermalink,
},
actionBarFocused: true,
});
}
private onCloseMenu = (): void => {
this.setState({
contextMenu: null,
actionBarFocused: false,
});
};
private setQuoteExpanded = (expanded: boolean) => {
this.setState({
isQuoteExpanded: expanded,
@ -941,6 +994,29 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {
return false;
}
private renderContextMenu(): React.ReactFragment {
if (!this.state.contextMenu) return null;
const tile = this.getTile();
const replyChain = this.getReplyChain();
const eventTileOps = tile?.getEventTileOps ? tile.getEventTileOps() : undefined;
const collapseReplyChain = replyChain?.canCollapse() ? replyChain.collapse : undefined;
return (
<MessageContextMenu
{...aboveLeftOf(this.state.contextMenu.position)}
mxEvent={this.props.mxEvent}
permalinkCreator={this.props.permalinkCreator}
eventTileOps={eventTileOps}
collapseReplyChain={collapseReplyChain}
onFinished={this.onCloseMenu}
rightClick={true}
reactions={this.state.reactions}
showPermalink={this.state.contextMenu.showPermalink}
/>
);
}
public render() {
const msgtype = this.props.mxEvent.getContent().msgtype;
const eventType = this.props.mxEvent.getType() as EventType;
@ -1004,8 +1080,10 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {
mx_EventTile_12hr: this.props.isTwelveHour,
// Note: we keep the `sending` state class for tests, not for our styles
mx_EventTile_sending: !isEditing && isSending,
mx_EventTile_highlight: this.shouldHighlight(),
mx_EventTile_selected: this.props.isSelectedEvent,
mx_EventTile_highlight: (this.context.timelineRenderingType === TimelineRenderingType.Notification
? false
: this.shouldHighlight()),
mx_EventTile_selected: this.props.isSelectedEvent || this.state.contextMenu,
mx_EventTile_continuation: isContinuation || eventType === EventType.CallInvite,
mx_EventTile_last: this.props.last,
mx_EventTile_lastInSection: this.props.lastInSection,
@ -1126,7 +1204,8 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {
&& (this.props.alwaysShowTimestamps
|| this.props.last
|| this.state.hover
|| this.state.actionBarFocused);
|| this.state.actionBarFocused
|| Boolean(this.state.contextMenu));
// Thread panel shows the timestamp of the last reply in that thread
const ts = this.context.timelineRenderingType !== TimelineRenderingType.ThreadsList
@ -1197,6 +1276,7 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {
href={permalink}
onClick={this.onPermalinkClicked}
aria-label={formatTime(new Date(this.props.mxEvent.getTs()), this.props.isTwelveHour)}
onContextMenu={this.onTimestampContextMenu}
>
{ timestamp }
</a>;
@ -1252,12 +1332,17 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {
</div>,
<div className="mx_EventTile_senderDetails" key="mx_EventTile_senderDetails">
{ avatar }
<a href={permalink} onClick={this.onPermalinkClicked}>
<a
href={permalink}
onClick={this.onPermalinkClicked}
onContextMenu={this.onTimestampContextMenu}
>
{ sender }
{ timestamp }
</a>
</div>,
<div className={lineClasses} key="mx_EventTile_line">
<div className={lineClasses} key="mx_EventTile_line" onContextMenu={this.onContextMenu}>
{ this.renderContextMenu() }
{ renderTile(TimelineRenderingType.Notification, {
...this.props,
@ -1298,7 +1383,8 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {
{ avatar }
{ sender }
</div>,
<div className={lineClasses} key="mx_EventTile_line">
<div className={lineClasses} key="mx_EventTile_line" onContextMenu={this.onContextMenu}>
{ this.renderContextMenu() }
{ replyChain }
{ renderTile(TimelineRenderingType.Thread, {
...this.props,
@ -1385,7 +1471,8 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {
"aria-atomic": true,
"data-scroll-tokens": scrollToken,
}, [
<div className={lineClasses} key="mx_EventTile_line">
<div className={lineClasses} key="mx_EventTile_line" onContextMenu={this.onContextMenu}>
{ this.renderContextMenu() }
{ renderTile(TimelineRenderingType.File, {
...this.props,
@ -1406,7 +1493,10 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {
href={permalink}
onClick={this.onPermalinkClicked}
>
<div className="mx_EventTile_senderDetails">
<div
className="mx_EventTile_senderDetails"
onContextMenu={this.onTimestampContextMenu}
>
{ sender }
{ timestamp }
</div>
@ -1434,7 +1524,8 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {
{ sender }
{ ircPadlock }
{ avatar }
<div className={lineClasses} key="mx_EventTile_line">
<div className={lineClasses} key="mx_EventTile_line" onContextMenu={this.onContextMenu}>
{ this.renderContextMenu() }
{ groupTimestamp }
{ groupPadlock }
{ replyChain }

View File

@ -897,6 +897,7 @@
"Right panel stays open (defaults to room member list)": "Right panel stays open (defaults to room member list)",
"Jump to date (adds /jumptodate and jump to date headers)": "Jump to date (adds /jumptodate and jump to date headers)",
"Don't send read receipts": "Don't send read receipts",
"Right-click message context menu": "Right-click message context menu",
"Location sharing - pin drop (under active development)": "Location sharing - pin drop (under active development)",
"Live location sharing - share current location (active development, and temporarily, locations persist in room history)": "Live location sharing - share current location (active development, and temporarily, locations persist in room history)",
"Font size": "Font size",
@ -2882,6 +2883,7 @@
"Forward": "Forward",
"View source": "View source",
"Show preview": "Show preview",
"Copy link": "Copy link",
"Source URL": "Source URL",
"Collapse reply thread": "Collapse reply thread",
"Report": "Report",

View File

@ -414,6 +414,13 @@ export const SETTINGS: {[setting: string]: ISetting} = {
displayName: _td("Don't send read receipts"),
default: false,
},
"feature_message_right_click_context_menu": {
isFeature: true,
supportedLevels: LEVELS_FEATURE,
labsGroup: LabGroup.Rooms,
displayName: _td("Right-click message context menu"),
default: false,
},
"feature_location_share_pin_drop": {
isFeature: true,
labsGroup: LabGroup.Messaging,