/* Copyright 2024 New Vector Ltd. Copyright 2015-2023 The Matrix.org Foundation C.I.C. Copyright 2021, 2022 Šimon Brandner Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ import React, { createRef, useContext } from "react"; import { EventStatus, MatrixEvent, MatrixEventEvent, RoomMemberEvent, EventType, RelationType, Relations, Thread, M_POLL_START, } from "matrix-js-sdk/src/matrix"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import dis from "../../../dispatcher/dispatcher"; import { _t } from "../../../languageHandler"; import Modal from "../../../Modal"; import Resend from "../../../Resend"; import SettingsStore from "../../../settings/SettingsStore"; import { isUrlPermitted } from "../../../HtmlUtils"; import { canEditContent, editEvent, isContentActionable } from "../../../utils/EventUtils"; import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from "./IconizedContextMenu"; import { Action } from "../../../dispatcher/actions"; import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import { ButtonEvent } from "../elements/AccessibleButton"; import { copyPlaintext, getSelectedText } from "../../../utils/strings"; import ContextMenu, { toRightOf, MenuProps } from "../../structures/ContextMenu"; import ReactionPicker from "../emojipicker/ReactionPicker"; import ViewSource from "../../structures/ViewSource"; import { createRedactEventDialog } from "../dialogs/ConfirmRedactDialog"; import ShareDialog from "../dialogs/ShareDialog"; import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; import EndPollDialog from "../dialogs/EndPollDialog"; import { isPollEnded } from "../messages/MPollBody"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { GetRelationsForEvent, IEventTileOps } from "../rooms/EventTile"; import { OpenForwardDialogPayload } from "../../../dispatcher/payloads/OpenForwardDialogPayload"; import { OpenReportEventDialogPayload } from "../../../dispatcher/payloads/OpenReportEventDialogPayload"; import { createMapSiteLinkFromEvent } from "../../../utils/location"; import { getForwardableEvent } from "../../../events/forward/getForwardableEvent"; import { getShareableLocationEvent } from "../../../events/location/getShareableLocationEvent"; import { ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload"; import { CardContext } from "../right_panel/context"; import PinningUtils from "../../../utils/PinningUtils"; import PosthogTrackers from "../../../PosthogTrackers.ts"; interface IReplyInThreadButton { mxEvent: MatrixEvent; closeMenu: () => void; } const ReplyInThreadButton: React.FC = ({ mxEvent, closeMenu }) => { const context = useContext(CardContext); const relationType = mxEvent?.getRelation()?.rel_type; // Can't create a thread from an event with an existing relation if (Boolean(relationType) && relationType !== RelationType.Thread) return null; const onClick = (): void => { if (mxEvent.getThread() && !mxEvent.isThreadRoot) { dis.dispatch({ action: Action.ShowThread, rootEvent: mxEvent.getThread()!.rootEvent!, initialEvent: mxEvent, scroll_into_view: true, highlighted: true, push: context.isCard, }); } else { dis.dispatch({ action: Action.ShowThread, rootEvent: mxEvent, push: context.isCard, }); } closeMenu(); }; return ( ); }; interface IProps extends MenuProps { /* the MatrixEvent associated with the context menu */ mxEvent: MatrixEvent; // 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) onCloseDialog?(): void; // 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 | null; // A permalink to this event or an href of an anchor element the user has clicked link?: string; getRelationsForEvent?: GetRelationsForEvent; } interface IState { canRedact: boolean; canPin: boolean; reactionPickerDisplayed: boolean; } export default class MessageContextMenu extends React.Component { public static contextType = RoomContext; declare public context: React.ContextType; private reactButtonRef = createRef(); // XXX Ref to a functional component public constructor(props: IProps, context: React.ContextType) { super(props, context); this.state = { canRedact: false, canPin: false, reactionPickerDisplayed: false, }; } public componentDidMount(): void { MatrixClientPeg.safeGet().on(RoomMemberEvent.PowerLevel, this.checkPermissions); // re-check the permissions on send progress (`maySendRedactionForEvent` only returns true for events that have // been fully sent and echoed back, and we want to ensure the "Remove" option is added once that happens.) this.props.mxEvent.on(MatrixEventEvent.Status, this.checkPermissions); this.checkPermissions(); } public componentWillUnmount(): void { const cli = MatrixClientPeg.get(); if (cli) { cli.removeListener(RoomMemberEvent.PowerLevel, this.checkPermissions); } this.props.mxEvent.removeListener(MatrixEventEvent.Status, this.checkPermissions); } private checkPermissions = (): void => { const cli = MatrixClientPeg.safeGet(); const room = cli.getRoom(this.props.mxEvent.getRoomId()); // We explicitly decline to show the redact option on ACL events as it has a potential // to obliterate the room - https://github.com/matrix-org/synapse/issues/4042 // Similarly for encryption events, since redacting them "breaks everything" const canRedact = !!room?.currentState.maySendRedactionForEvent(this.props.mxEvent, cli.getSafeUserId()) && this.props.mxEvent.getType() !== EventType.RoomServerAcl && this.props.mxEvent.getType() !== EventType.RoomEncryption; const canPin = PinningUtils.canPin(cli, this.props.mxEvent) || PinningUtils.canUnpin(cli, this.props.mxEvent); this.setState({ canRedact, canPin }); }; private canEndPoll(mxEvent: MatrixEvent): boolean { return ( M_POLL_START.matches(mxEvent.getType()) && this.state.canRedact && !isPollEnded(mxEvent, MatrixClientPeg.safeGet()) ); } private onResendReactionsClick = (): void => { for (const reaction of this.getUnsentReactions()) { Resend.resend(MatrixClientPeg.safeGet(), reaction); } this.closeMenu(); }; private onJumpToRelatedEventClick = (relatedEventId: string): void => { dis.dispatch({ action: "view_room", room_id: this.props.mxEvent.getRoomId(), event_id: relatedEventId, highlighted: true, }); }; private onReportEventClick = (): void => { dis.dispatch({ action: Action.OpenReportEventDialog, event: this.props.mxEvent, }); this.closeMenu(); }; private onViewSourceClick = (): void => { Modal.createDialog( ViewSource, { mxEvent: this.props.mxEvent, }, "mx_Dialog_viewsource", ); this.closeMenu(); }; private onRedactClick = (): void => { const { mxEvent, onCloseDialog } = this.props; createRedactEventDialog({ mxEvent, onCloseDialog, }); this.closeMenu(); }; private onForwardClick = (forwardableEvent: MatrixEvent) => (): void => { dis.dispatch({ action: Action.OpenForwardDialog, event: forwardableEvent, permalinkCreator: this.props.permalinkCreator, }); this.closeMenu(); }; private onPinClick = (isPinned: boolean): void => { // Pin or unpin in background PinningUtils.pinOrUnpinEvent(MatrixClientPeg.safeGet(), this.props.mxEvent); PosthogTrackers.trackPinUnpinMessage(isPinned ? "Pin" : "Unpin", "Timeline"); this.closeMenu(); }; private closeMenu = (): void => { this.props.onFinished(); }; private onUnhidePreviewClick = (): void => { this.props.eventTileOps?.unhideWidget(); this.closeMenu(); }; private onShareClick = (e: ButtonEvent): void => { e.preventDefault(); Modal.createDialog(ShareDialog, { target: this.props.mxEvent, permalinkCreator: this.props.permalinkCreator, }); this.closeMenu(); }; private onCopyLinkClick = (e: ButtonEvent): void => { e.preventDefault(); // So that we don't open the permalink if (!this.props.link) return; copyPlaintext(this.props.link); this.closeMenu(); }; private onCollapseReplyChainClick = (): void => { this.props.collapseReplyChain?.(); this.closeMenu(); }; private onCopyClick = (): void => { copyPlaintext(getSelectedText()); this.closeMenu(); }; private onEditClick = (): void => { editEvent( MatrixClientPeg.safeGet(), 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.safeGet(); Modal.createDialog( EndPollDialog, { matrixClient, event: this.props.mxEvent, getRelationsForEvent: this.props.getRelationsForEvent, }, "mx_Dialog_endPoll", ); this.closeMenu(); }; private getReactions(filter: (e: MatrixEvent) => boolean): MatrixEvent[] { const cli = MatrixClientPeg.safeGet(); const room = cli.getRoom(this.props.mxEvent.getRoomId()); const eventId = this.props.mxEvent.getId(); return ( room?.getPendingEvents().filter((e) => { const relation = e.getRelation(); return relation?.rel_type === RelationType.Annotation && relation.event_id === eventId && filter(e); }) ?? [] ); } private getUnsentReactions(): MatrixEvent[] { return this.getReactions((e) => e.status === EventStatus.NOT_SENT); } private viewInRoom = (): void => { dis.dispatch({ action: Action.ViewRoom, event_id: this.props.mxEvent.getId(), highlighted: true, room_id: this.props.mxEvent.getRoomId(), metricsTrigger: undefined, // room doesn't change }); this.closeMenu(); }; public render(): React.ReactNode { const cli = MatrixClientPeg.safeGet(); const me = cli.getUserId(); const { mxEvent, rightClick, link, eventTileOps, reactions, collapseReplyChain, ...other } = this.props; delete other.getRelationsForEvent; delete other.permalinkCreator; const eventStatus = mxEvent.status; const unsentReactionsCount = this.getUnsentReactions().length; const contentActionable = isContentActionable(mxEvent); const permalink = this.props.permalinkCreator?.forEvent(this.props.mxEvent.getId()!); // 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 resendReactionsButton: JSX.Element | undefined; if (!mxEvent.isRedacted() && unsentReactionsCount !== 0) { resendReactionsButton = ( ); } let redactButton: JSX.Element | undefined; if (isSent && this.state.canRedact) { redactButton = ( ); } let openInMapSiteButton: JSX.Element | undefined; const shareableLocationEvent = getShareableLocationEvent(mxEvent, cli); if (shareableLocationEvent) { const mapSiteLink = createMapSiteLinkFromEvent(shareableLocationEvent); openInMapSiteButton = ( ); } let forwardButton: JSX.Element | undefined; const forwardableEvent = getForwardableEvent(mxEvent, cli); if (contentActionable && forwardableEvent) { forwardButton = ( ); } // This is specifically not behind the developerMode flag to give people insight into the Matrix const viewSourceButton = ( ); let unhidePreviewButton: JSX.Element | undefined; if (eventTileOps?.isWidgetHidden()) { unhidePreviewButton = ( ); } let permalinkButton: JSX.Element | undefined; if (permalink) { permalinkButton = ( ); } let endPollButton: JSX.Element | undefined; if (this.canEndPoll(mxEvent)) { endPollButton = ( ); } // Bridges can provide a 'external_url' to link back to the source. let externalURLButton: JSX.Element | undefined; if ( typeof mxEvent.getContent().external_url === "string" && isUrlPermitted(mxEvent.getContent().external_url) ) { externalURLButton = ( ); } let collapseReplyChainButton: JSX.Element | undefined; if (collapseReplyChain) { collapseReplyChainButton = ( ); } let jumpToRelatedEventButton: JSX.Element | undefined; const relatedEventId = mxEvent.getAssociatedId(); if (relatedEventId && SettingsStore.getValue("developerMode")) { jumpToRelatedEventButton = ( this.onJumpToRelatedEventClick(relatedEventId)} /> ); } let reportEventButton: JSX.Element | undefined; if (mxEvent.getSender() !== me) { reportEventButton = ( ); } let copyLinkButton: JSX.Element | undefined; if (link) { copyLinkButton = ( ); } let copyButton: JSX.Element | undefined; if (rightClick && getSelectedText()) { copyButton = ( ); } let editButton: JSX.Element | undefined; if (rightClick && canEditContent(cli, mxEvent)) { editButton = ( ); } let replyButton: JSX.Element | undefined; if (rightClick && contentActionable && canSendMessages) { replyButton = ( ); } let replyInThreadButton: JSX.Element | undefined; if ( rightClick && contentActionable && canSendMessages && Thread.hasServerSideSupport && timelineRenderingType !== TimelineRenderingType.Thread ) { replyInThreadButton = ; } let reactButton: JSX.Element | undefined; if (rightClick && contentActionable && canReact) { reactButton = ( ); } let pinButton: JSX.Element | undefined; if (rightClick && this.state.canPin) { const isPinned = PinningUtils.isPinned(MatrixClientPeg.safeGet(), this.props.mxEvent); pinButton = ( this.onPinClick(isPinned)} /> ); } let viewInRoomButton: JSX.Element | undefined; if (isThreadRootEvent) { viewInRoomButton = ( ); } let nativeItemsList: JSX.Element | undefined; if (copyButton || copyLinkButton) { nativeItemsList = ( {copyButton} {copyLinkButton} ); } let quickItemsList: JSX.Element | undefined; if (editButton || replyButton || reactButton || pinButton) { quickItemsList = ( {reactButton} {replyButton} {replyInThreadButton} {editButton} {pinButton} ); } const commonItemsList = ( {viewInRoomButton} {openInMapSiteButton} {endPollButton} {forwardButton} {permalinkButton} {reportEventButton} {externalURLButton} {jumpToRelatedEventButton} {unhidePreviewButton} {viewSourceButton} {resendReactionsButton} {collapseReplyChainButton} ); let redactItemList: JSX.Element | undefined; if (redactButton) { redactItemList = {redactButton}; } let reactionPicker: JSX.Element | undefined; if (this.state.reactionPickerDisplayed) { const buttonRect = (this.reactButtonRef.current as HTMLElement)?.getBoundingClientRect(); reactionPicker = ( ); } return ( {nativeItemsList} {quickItemsList} {commonItemsList} {redactItemList} {reactionPicker} ); } }