From d162e021e1fede4856aa29d25d27176173c59281 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Fri, 15 Apr 2022 16:22:59 +0200 Subject: [PATCH] Add message right click context menu v2 (#5672) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * migrate the message context menu to IconizedContextMenu Signed-off-by: Michael Weimann * migrate the message context menu to IconizedContextMenu Signed-off-by: Michael Weimann * Added right-click menu Signed-off-by: Šimon Brandner * add message context menu group keys Signed-off-by: Michael Weimann * add message context menu icons Signed-off-by: Michael Weimann * add _MessageContextMenu.scss license header Signed-off-by: Michael Weimann * use null vars for context menu lists * Add allowOverridingNativeContextMenus() Signed-off-by: Šimon Brandner * Use allowOverridingNativeContextMenus() Signed-off-by: Šimon Brandner * Fix types Signed-off-by: Šimon Brandner * Fix types Signed-off-by: Šimon Brandner * Remove mistaken line Signed-off-by: Šimon Brandner * Fix styling Signed-off-by: Šimon Brandner * InputHTMLAttributes -> AllHTMLAttributes Signed-off-by: Šimon Brandner * Convert to TS Signed-off-by: Šimon Brandner * Add some types Signed-off-by: Šimon Brandner * Make onClick optional Signed-off-by: Šimon Brandner * Add rightClick prop Signed-off-by: Šimon Brandner * Add copy button Signed-off-by: Šimon Brandner * What about upgrading deps after the eslint migration, Simon? Signed-off-by: Šimon Brandner * Add edit button Signed-off-by: Šimon Brandner * fix Signed-off-by: Šimon Brandner * Add reply button Signed-off-by: Šimon Brandner * Add react button Signed-off-by: Šimon Brandner * Cleanup render() Signed-off-by: Šimon Brandner * Fix comments Signed-off-by: Šimon Brandner * Add save button Signed-off-by: Šimon Brandner * Don't show context menu if editing Signed-off-by: Šimon Brandner * Add special handling for click a timestamp Signed-off-by: Šimon Brandner * Fix double empty line Signed-off-by: Šimon Brandner * Don't show context menu for images Signed-off-by: Šimon Brandner * Cleanup Signed-off-by: Šimon Brandner * Fix order Signed-off-by: Šimon Brandner * Keep action bar shown when right-clicking Signed-off-by: Šimon Brandner * Highlight event tile when right-clicking Signed-off-by: Šimon Brandner * Delint Signed-off-by: Šimon Brandner * Pointless change so that I can re-run the CI Signed-off-by: Šimon Brandner * Remove dowload button Because we don't use this menu when clicking on images Signed-off-by: Šimon Brandner * Be more clear for non-bools Signed-off-by: Šimon Brandner * Use triggerOnMouse down prop Signed-off-by: Šimon Brandner * Remove a comment Signed-off-by: Šimon Brandner * Remove unused var Signed-off-by: Šimon Brandner * Remove unnecessary import Signed-off-by: Šimon Brandner * Add some missing types Signed-off-by: Šimon Brandner * Add missing type Signed-off-by: Šimon Brandner * Remove unused import Signed-off-by: Šimon Brandner * Add a missing type Signed-off-by: Šimon Brandner * Fix types Signed-off-by: Šimon Brandner * Fix types/naming Signed-off-by: Šimon Brandner * Add missing current Signed-off-by: Šimon Brandner * Remove unused var Signed-off-by: Šimon Brandner * Fix editing and replying Signed-off-by: Šimon Brandner * i18n Signed-off-by: Šimon Brandner * Fix import Signed-off-by: Šimon Brandner * Support right-click context menu for threads Signed-off-by: Šimon Brandner * Make button order match `MessageActionBar` Signed-off-by: Šimon Brandner * Fix missing permalink button Signed-off-by: Šimon Brandner * Remove useless part of if statement Signed-off-by: Šimon Brandner * Some small refactoring for consistency Signed-off-by: Šimon Brandner * Some more refactoring Signed-off-by: Šimon Brandner * Fix `editEvent()` call Signed-off-by: Šimon Brandner * Make editing polls work Signed-off-by: Šimon Brandner * Fix collapse reply chain button Signed-off-by: Šimon Brandner * Fix timelineRenderingType Signed-off-by: Šimon Brandner * Fix reply button Signed-off-by: Šimon Brandner * Hide right-click context menu behind a labs flag Signed-off-by: Šimon Brandner * Add missing return type Signed-off-by: Šimon Brandner * Make `contextMene` optional Signed-off-by: Šimon Brandner * Move `renderContextMenu()` Signed-off-by: Šimon Brandner * Simplify `renderContextMenu()` Signed-off-by: Šimon Brandner * Improve `aboveLeftOf` typing Signed-off-by: Šimon Brandner * Use `InputHTMLAttributes` Signed-off-by: Šimon Brandner * Disable message right-click context menu in browser (for now) Signed-off-by: Šimon Brandner * Give permalink button more props Signed-off-by: Šimon Brandner Co-authored-by: Michael Weimann --- .../context_menus/_MessageContextMenu.scss | 16 + src/BasePlatform.ts | 7 + src/components/structures/ContextMenu.tsx | 2 +- .../context_menus/MessageContextMenu.tsx | 372 +++++++++++++----- .../views/elements/AccessibleButton.tsx | 8 +- .../elements/AccessibleTooltipButton.tsx | 2 +- src/components/views/rooms/EventTile.tsx | 119 +++++- src/i18n/strings/en_EN.json | 2 + src/settings/Settings.tsx | 7 + 9 files changed, 410 insertions(+), 125 deletions(-) diff --git a/res/css/views/context_menus/_MessageContextMenu.scss b/res/css/views/context_menus/_MessageContextMenu.scss index e743619f8fd..b92ce10d355 100644 --- a/res/css/views/context_menus/_MessageContextMenu.scss +++ b/res/css/views/context_menus/_MessageContextMenu.scss @@ -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'); } diff --git a/src/BasePlatform.ts b/src/BasePlatform.ts index 383d4eb29ee..b7f52d38952 100644 --- a/src/BasePlatform.ts +++ b/src/BasePlatform.ts @@ -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. diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index 2f233c02ed1..c84452a2646 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -429,7 +429,7 @@ export type AboveLeftOf = IPosition & { // Placement method for 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, chevronFace = ChevronFace.None, vPadding = 0, ): AboveLeftOf => { diff --git a/src/components/views/context_menus/MessageContextMenu.tsx b/src/components/views/context_menus/MessageContextMenu.tsx index 75cdebc9535..1f7f6ac62c4 100644 --- a/src/components/views/context_menus/MessageContextMenu.tsx +++ b/src/components/views/context_menus/MessageContextMenu.tsx @@ -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 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 { static contextType = RoomContext; public context!: React.ContextType; - state = { - canRedact: false, - canPin: false, - }; + private reactButtonRef = createRef(); // XXX Ref to a functional component + + constructor(props: IProps) { + super(props); + + this.state = { + canRedact: false, + canPin: false, + reactionPickerDisplayed: false, + }; + } - componentDidMount() { + 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 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 }); } + 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({ action: Action.ViewRoom, event_id: this.props.mxEvent.getId(), @@ -273,12 +334,22 @@ export default class MessageContextMenu extends React.Component 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 let unhidePreviewButton: JSX.Element; let externalURLButton: JSX.Element; let quoteButton: JSX.Element; - let collapseReplyChain: JSX.Element; let redactItemList: JSX.Element; - - // status is SENT before remote-echo, null after - const isSent = !eventStatus || eventStatus === EventStatus.SENT; - if (!mxEvent.isRedacted()) { - if (unsentReactionsCount !== 0) { - resendReactionsButton = ( - - ); - } + 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; + + if (!mxEvent.isRedacted() && unsentReactionsCount !== 0) { + resendReactionsButton = ( + + ); } if (isSent && this.state.canRedact) { @@ -335,26 +412,24 @@ export default class MessageContextMenu extends React.Component ); } - if (isContentActionable(mxEvent)) { - if (canForward(mxEvent)) { - forwardButton = ( - - ); - } - - if (this.state.canPin) { - pinButton = ( - - ); - } + if (contentActionable && canForward(mxEvent)) { + forwardButton = ( + + ); + } + + if (contentActionable && this.state.canPin) { + pinButton = ( + + ); } let viewSourceButton: JSX.Element; @@ -368,39 +443,38 @@ export default class MessageContextMenu extends React.Component ); } - if (this.props.eventTileOps) { - if (this.props.eventTileOps.isWidgetHidden()) { - unhidePreviewButton = ( - - ); - } + if (eventTileOps?.isWidgetHidden()) { + unhidePreviewButton = ( + + ); } - let permalink: string | null = null; - let permalinkButton: ReactElement | null = null; - if (this.props.permalinkCreator) { - permalink = this.props.permalinkCreator.forEvent(this.props.mxEvent.getId()); - } - permalinkButton = ( - - ); + 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 ); } - if (this.props.eventTileOps) { // this event is rendered using TextualBody + if (eventTileOps) { // this event is rendered using TextualBody quoteButton = ( } // 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 ); } - if (this.props.collapseReplyChain) { - collapseReplyChain = ( + if (collapseReplyChain) { + collapseReplyChainButton = ( ); } - let reportEventButton: JSX.Element; if (mxEvent.getSender() !== me) { reportEventButton = ( ); } - const { timelineRenderingType } = this.context; - const isThread = ( - timelineRenderingType === TimelineRenderingType.Thread || - timelineRenderingType === TimelineRenderingType.ThreadsList - ); - const isThreadRootEvent = isThread && this.props.mxEvent.isThreadRoot; + if (rightClick && this.getSelectedText()) { + copyButton = ( + + ); + } - const commonItemsList = ( - - { isThreadRootEvent && + ); + } + + if (rightClick && contentActionable && canSendMessages) { + replyButton = ( + + ); + } + + if (rightClick && contentActionable && canReact) { + reactButton = ( + + ); + } + + if (isThreadRootEvent) { + viewInRoomButton = ( + } + /> + ); + } + + if (copyButton) { + nativeItemsList = ( + + { copyButton } + + ); + } + + if (editButton || replyButton || reactButton) { + quickItemsList = ( + + { reactButton } + { replyButton } + { editButton } + + ); + } + + const commonItemsList = ( + + { viewInRoomButton } { openInMapSiteButton } { endPollButton } { quoteButton } @@ -490,7 +623,7 @@ export default class MessageContextMenu extends React.Component { unhidePreviewButton } { viewSourceButton } { resendReactionsButton } - { collapseReplyChain } + { collapseReplyChainButton } ); @@ -501,15 +634,38 @@ export default class MessageContextMenu extends React.Component ); } + + if (this.state.reactionPickerDisplayed) { + const buttonRect = (this.reactButtonRef.current as HTMLElement)?.getBoundingClientRect(); + reactionPicker = ( + + + + ); + } + return ( - - { commonItemsList } - { redactItemList } - + + + { nativeItemsList } + { quickItemsList } + { commonItemsList } + { redactItemList } + + { reactionPicker } + ); } } diff --git a/src/components/views/elements/AccessibleButton.tsx b/src/components/views/elements/AccessibleButton.tsx index bc96bfe7084..3db6d0dfb05 100644 --- a/src/components/views/elements/AccessibleButton.tsx +++ b/src/components/views/elements/AccessibleButton.tsx @@ -53,6 +53,7 @@ interface IProps extends React.InputHTMLAttributes { tabIndex?: number; disabled?: boolean; className?: string; + triggerOnMouseDown?: boolean; onClick(e?: ButtonEvent): void | Promise; } @@ -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 diff --git a/src/components/views/elements/AccessibleTooltipButton.tsx b/src/components/views/elements/AccessibleTooltipButton.tsx index d7a001460ea..f0be8ba12d8 100644 --- a/src/components/views/elements/AccessibleTooltipButton.tsx +++ b/src/components/views/elements/AccessibleTooltipButton.tsx @@ -23,7 +23,7 @@ import Tooltip, { Alignment } from './Tooltip'; interface IProps extends React.ComponentProps { title: string; tooltip?: React.ReactNode; - label?: React.ReactNode; + label?: string; tooltipClassName?: string; forceHide?: boolean; yOffset?: number; diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 0dc89c57f4b..79ba61b6a4a 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -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; + showPermalink?: boolean; + }; + isQuoteExpanded?: boolean; thread: Thread; @@ -230,8 +244,7 @@ interface IState { export class UnwrappedEventTile extends React.Component { private suppressReadReceiptAnimation: boolean; private isListeningForReceipts: boolean; - // TODO: Types - private tile = React.createRef(); + private tile = React.createRef(); private replyChain = React.createRef(); private threadState: ThreadNotificationState; @@ -264,6 +277,8 @@ export class UnwrappedEventTile extends React.Component { 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 { 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 { }); }; + 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 { 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 ( + + ); + } + 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 { 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 { && (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 { href={permalink} onClick={this.onPermalinkClicked} aria-label={formatTime(new Date(this.props.mxEvent.getTs()), this.props.isTwelveHour)} + onContextMenu={this.onTimestampContextMenu} > { timestamp } ; @@ -1252,12 +1332,17 @@ export class UnwrappedEventTile extends React.Component { , , -
+
+ { this.renderContextMenu() } { renderTile(TimelineRenderingType.Notification, { ...this.props, @@ -1298,7 +1383,8 @@ export class UnwrappedEventTile extends React.Component { { avatar } { sender }
, -
+
+ { this.renderContextMenu() } { replyChain } { renderTile(TimelineRenderingType.Thread, { ...this.props, @@ -1385,7 +1471,8 @@ export class UnwrappedEventTile extends React.Component { "aria-atomic": true, "data-scroll-tokens": scrollToken, }, [ -
+
+ { this.renderContextMenu() } { renderTile(TimelineRenderingType.File, { ...this.props, @@ -1406,7 +1493,10 @@ export class UnwrappedEventTile extends React.Component { href={permalink} onClick={this.onPermalinkClicked} > -
+
{ sender } { timestamp }
@@ -1434,7 +1524,8 @@ export class UnwrappedEventTile extends React.Component { { sender } { ircPadlock } { avatar } -
+
+ { this.renderContextMenu() } { groupTimestamp } { groupPadlock } { replyChain } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 081a2806873..bbcb3e37b0e 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -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", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 2de76013636..559558cee88 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -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,