diff --git a/src/components/views/context_menus/MessageContextMenu.tsx b/src/components/views/context_menus/MessageContextMenu.tsx index 76f6b31989..8d6f6cc6eb 100644 --- a/src/components/views/context_menus/MessageContextMenu.tsx +++ b/src/components/views/context_menus/MessageContextMenu.tsx @@ -183,6 +183,30 @@ export default class MessageContextMenu extends React.Component ); } + /** + * Returns true if the current selection is entirely within a single "mx_MTextBody" element. + */ + private isSelectionWithinSingleTextBody(): boolean { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) return false; + const range = selection.getRangeAt(0); + + function getParentByClass(node: Node | null, className: string): HTMLElement | null { + while (node) { + if (node instanceof HTMLElement && node.classList.contains(className)) { + return node; + } + node = node.parentNode; + } + return null; + } + + const startTextBody = getParentByClass(range.startContainer, "mx_MTextBody"); + const endTextBody = getParentByClass(range.endContainer, "mx_MTextBody"); + + return !!startTextBody && startTextBody === endTextBody; + } + private onResendReactionsClick = (): void => { for (const reaction of this.getUnsentReactions()) { Resend.resend(MatrixClientPeg.safeGet(), reaction); @@ -279,6 +303,24 @@ export default class MessageContextMenu extends React.Component this.closeMenu(); }; + private onQuoteClick = (): void => { + const selectedText = getSelectedText(); + if (selectedText) { + // Format as markdown quote + const quotedText = selectedText + .trim() + .split(/\r?\n/) + .map((line) => `> ${line}`) + .join("\n"); + dis.dispatch({ + action: Action.ComposerInsert, + text: "\n" + quotedText + "\n\n ", + timelineRenderingType: this.context.timelineRenderingType, + }); + } + this.closeMenu(); + }; + private onEditClick = (): void => { editEvent( MatrixClientPeg.safeGet(), @@ -549,8 +591,10 @@ export default class MessageContextMenu extends React.Component ); } + const selectedText = getSelectedText(); + let copyButton: JSX.Element | undefined; - if (rightClick && getSelectedText()) { + if (rightClick && selectedText) { copyButton = ( ); } + let quoteButton: JSX.Element | undefined; + if (rightClick && selectedText && selectedText.trim().length > 0 && this.isSelectionWithinSingleTextBody()) { + quoteButton = ( + + ); + } + let editButton: JSX.Element | undefined; if (rightClick && canEditContent(cli, mxEvent)) { editButton = ( @@ -630,10 +686,11 @@ export default class MessageContextMenu extends React.Component } let nativeItemsList: JSX.Element | undefined; - if (copyButton || copyLinkButton) { + if (copyButton || quoteButton || copyLinkButton) { nativeItemsList = ( {copyButton} + {quoteButton} {copyLinkButton} ); diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index afef8b92e0..27afc4debb 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -64,7 +64,7 @@ import { getEventDisplayInfo } from "../../../utils/EventRenderingUtils"; import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; import { MediaEventHelper } from "../../../utils/MediaEventHelper"; import { type ButtonEvent } from "../elements/AccessibleButton"; -import { copyPlaintext, getSelectedText } from "../../../utils/strings"; +import { copyPlaintext } from "../../../utils/strings"; import { DecryptionFailureTracker } from "../../../DecryptionFailureTracker"; import RedactedBody from "../messages/RedactedBody"; import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; @@ -840,10 +840,8 @@ export class UnwrappedEventTile extends React.Component // Electron layer (webcontents-handler.ts) if (clickTarget instanceof HTMLImageElement) return; - // Return if we're in a browser and click either an a tag or we have - // selected text, as in those cases we want to use the native browser - // menu - if (!PlatformPeg.get()?.allowOverridingNativeContextMenus() && (getSelectedText() || anchorElement)) return; + // Return if we're in a browser and click either an a tag, as in those cases we want to use the native browser menu + if (!PlatformPeg.get()?.allowOverridingNativeContextMenus() && anchorElement) return; // We don't want to show the menu when editing a message if (this.props.editState) return; diff --git a/test/unit-tests/components/views/context_menus/MessageContextMenu-test.tsx b/test/unit-tests/components/views/context_menus/MessageContextMenu-test.tsx index 411cc5d0e4..4c735bdd8c 100644 --- a/test/unit-tests/components/views/context_menus/MessageContextMenu-test.tsx +++ b/test/unit-tests/components/views/context_menus/MessageContextMenu-test.tsx @@ -356,6 +356,226 @@ describe("MessageContextMenu", () => { }); }); + describe("quote button", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("shows quote button when selection is inside one MTextBody and getSelectedText returns text", () => { + mocked(getSelectedText).mockReturnValue("quoted text"); + const isSelectionWithinSingleTextBody = jest + .spyOn(MessageContextMenu.prototype as any, "isSelectionWithinSingleTextBody") + .mockReturnValue(true); + + createRightClickMenuWithContent(createMessageEventContent("hello")); + const quoteButton = document.querySelector('li[aria-label="Quote"]'); + expect(quoteButton).toBeTruthy(); + + isSelectionWithinSingleTextBody.mockRestore(); + }); + + it("does not show quote button when getSelectedText returns empty", () => { + mocked(getSelectedText).mockReturnValue(""); + const isSelectionWithinSingleTextBody = jest + .spyOn(MessageContextMenu.prototype as any, "isSelectionWithinSingleTextBody") + .mockReturnValue(true); + + createRightClickMenuWithContent(createMessageEventContent("hello")); + const quoteButton = document.querySelector('li[aria-label="Quote"]'); + expect(quoteButton).toBeFalsy(); + + isSelectionWithinSingleTextBody.mockRestore(); + }); + + it("does not show quote button when selection is not inside one MTextBody", () => { + mocked(getSelectedText).mockReturnValue("quoted text"); + const isSelectionWithinSingleTextBody = jest + .spyOn(MessageContextMenu.prototype as any, "isSelectionWithinSingleTextBody") + .mockReturnValue(false); + + createRightClickMenuWithContent(createMessageEventContent("hello")); + const quoteButton = document.querySelector('li[aria-label="Quote"]'); + expect(quoteButton).toBeFalsy(); + + isSelectionWithinSingleTextBody.mockRestore(); + }); + + it("dispatches ComposerInsert with quoted text when quote button is clicked", () => { + mocked(getSelectedText).mockReturnValue("line1\nline2"); + const dispatchSpy = jest.spyOn(dispatcher, "dispatch"); + const isSelectionWithinSingleTextBody = jest + .spyOn(MessageContextMenu.prototype as any, "isSelectionWithinSingleTextBody") + .mockReturnValue(true); + + createRightClickMenuWithContent(createMessageEventContent("hello")); + const quoteButton = document.querySelector('li[aria-label="Quote"]')!; + fireEvent.mouseDown(quoteButton); + + expect(dispatchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + action: Action.ComposerInsert, + text: "\n> line1\n> line2\n\n ", + }), + ); + + isSelectionWithinSingleTextBody.mockRestore(); + }); + + it("does not show quote button when getSelectedText returns only whitespace", () => { + mocked(getSelectedText).mockReturnValue(" \n\t "); // whitespace only + const isSelectionWithinSingleTextBody = jest + .spyOn(MessageContextMenu.prototype as any, "isSelectionWithinSingleTextBody") + .mockReturnValue(true); + + createRightClickMenuWithContent(createMessageEventContent("hello")); + const quoteButton = document.querySelector('li[aria-label="Quote"]'); + expect(quoteButton).toBeFalsy(); + + isSelectionWithinSingleTextBody.mockRestore(); + }); + }); + + describe("isSelectionWithinSingleTextBody", () => { + let mockGetSelection: jest.SpyInstance; + let contextMenuInstance: MessageContextMenu; + + beforeEach(() => { + jest.clearAllMocks(); + + mockGetSelection = jest.spyOn(window, "getSelection"); + + const eventContent = createMessageEventContent("hello"); + const mxEvent = new MatrixEvent({ type: EventType.RoomMessage, content: eventContent }); + + contextMenuInstance = new MessageContextMenu({ + mxEvent, + onFinished: jest.fn(), + rightClick: true, + } as any); + }); + + afterEach(() => { + mockGetSelection.mockRestore(); + }); + + it("returns false when there is no selection", () => { + mockGetSelection.mockReturnValue(null); + + const result = (contextMenuInstance as any).isSelectionWithinSingleTextBody(); + expect(result).toBe(false); + }); + + it("returns false when selection has no ranges", () => { + mockGetSelection.mockReturnValue({ + rangeCount: 0, + getRangeAt: jest.fn(), + } as any); + + const result = (contextMenuInstance as any).isSelectionWithinSingleTextBody(); + expect(result).toBe(false); + }); + + it("returns true when selection is within a single mx_MTextBody element", () => { + // Create a mock MTextBody element + const textBodyElement = document.createElement("div"); + textBodyElement.classList.add("mx_MTextBody"); + + // Create mock text nodes within the MTextBody + const startTextNode = document.createTextNode("start"); + const endTextNode = document.createTextNode("end"); + textBodyElement.appendChild(startTextNode); + textBodyElement.appendChild(endTextNode); + + // Create a mock range with the text nodes + const mockRange = { + startContainer: startTextNode, + endContainer: endTextNode, + } as unknown as Range; + + mockGetSelection.mockReturnValue({ + rangeCount: 1, + getRangeAt: jest.fn().mockReturnValue(mockRange), + } as any); + + const result = (contextMenuInstance as any).isSelectionWithinSingleTextBody(); + expect(result).toBe(true); + }); + + it("returns false when selection spans multiple mx_MTextBody elements", () => { + // Create two different MTextBody elements + const textBody1 = document.createElement("div"); + textBody1.classList.add("mx_MTextBody"); + const textBody2 = document.createElement("div"); + textBody2.classList.add("mx_MTextBody"); + + const startTextNode = document.createTextNode("start"); + const endTextNode = document.createTextNode("end"); + textBody1.appendChild(startTextNode); + textBody2.appendChild(endTextNode); + + // Create a mock range spanning different MTextBody elements + const mockRange = { + startContainer: startTextNode, + endContainer: endTextNode, + } as unknown as Range; + + mockGetSelection.mockReturnValue({ + rangeCount: 1, + getRangeAt: jest.fn().mockReturnValue(mockRange), + } as any); + + const result = (contextMenuInstance as any).isSelectionWithinSingleTextBody(); + expect(result).toBe(false); + }); + + it("returns false when selection is outside any mx_MTextBody element", () => { + // Create regular div elements without mx_MTextBody class + const regularDiv1 = document.createElement("div"); + const regularDiv2 = document.createElement("div"); + + const startTextNode = document.createTextNode("start"); + const endTextNode = document.createTextNode("end"); + regularDiv1.appendChild(startTextNode); + regularDiv2.appendChild(endTextNode); + + // Create a mock range outside MTextBody elements + const mockRange = { + startContainer: startTextNode, + endContainer: endTextNode, + } as unknown as Range; + + mockGetSelection.mockReturnValue({ + rangeCount: 1, + getRangeAt: jest.fn().mockReturnValue(mockRange), + } as any); + + const result = (contextMenuInstance as any).isSelectionWithinSingleTextBody(); + expect(result).toBe(false); + }); + + it("returns true when start and end are the same mx_MTextBody element", () => { + const textBodyElement = document.createElement("div"); + textBodyElement.classList.add("mx_MTextBody"); + + const textNode = document.createTextNode("same text"); + textBodyElement.appendChild(textNode); + + // Create a mock range within the same MTextBody element + const mockRange = { + startContainer: textNode, + endContainer: textNode, + } as unknown as Range; + + mockGetSelection.mockReturnValue({ + rangeCount: 1, + getRangeAt: jest.fn().mockReturnValue(mockRange), + } as any); + + const result = (contextMenuInstance as any).isSelectionWithinSingleTextBody(); + expect(result).toBe(true); + }); + }); + describe("right click", () => { it("copy button does work as expected", () => { const text = "hello";