From f922d73b494315e8578817ec00133810843ca010 Mon Sep 17 00:00:00 2001 From: Alireza Mortezaeifar Date: Tue, 15 Jul 2025 22:22:37 +0330 Subject: [PATCH 1/5] Add quote functionality to MessageContextMenu (#29893) --- .../context_menus/MessageContextMenu.tsx | 61 ++++- src/components/views/rooms/EventTile.tsx | 6 +- .../context_menus/MessageContextMenu-test.tsx | 220 ++++++++++++++++++ 3 files changed, 281 insertions(+), 6 deletions(-) diff --git a/src/components/views/context_menus/MessageContextMenu.tsx b/src/components/views/context_menus/MessageContextMenu.tsx index 76f6b319894..0d7c6ea99b8 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: quotedText, + 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 afef8b92e06..21b84520af0 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -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 411cc5d0e49..6d17d100b12 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: "> line1\n> line2", + }), + ); + + 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"; From 2c3d1fb35c10ce0f60b6ea39df0ba5c4dd2e96d0 Mon Sep 17 00:00:00 2001 From: Alireza Mortezaeifar Date: Wed, 16 Jul 2025 00:01:06 +0330 Subject: [PATCH 2/5] Remove unused import of getSelectedText from strings utility in EventTile component --- src/components/views/rooms/EventTile.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 21b84520af0..27afc4debba 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"; From a252f83f1f346c7c6a0ba3c3ee847e13cbaf7c34 Mon Sep 17 00:00:00 2001 From: Alireza Mortezaeifar Date: Wed, 16 Jul 2025 23:41:05 +0330 Subject: [PATCH 3/5] Add space after quoted text in ComposerInsert action --- src/components/views/context_menus/MessageContextMenu.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/context_menus/MessageContextMenu.tsx b/src/components/views/context_menus/MessageContextMenu.tsx index 0d7c6ea99b8..182778a3573 100644 --- a/src/components/views/context_menus/MessageContextMenu.tsx +++ b/src/components/views/context_menus/MessageContextMenu.tsx @@ -314,7 +314,7 @@ export default class MessageContextMenu extends React.Component .join("\n"); dis.dispatch({ action: Action.ComposerInsert, - text: quotedText, + text: quotedText + "\n ", timelineRenderingType: this.context.timelineRenderingType, }); } From 816e33ce53e956dcdbae00cb77797ad1ca7ab472 Mon Sep 17 00:00:00 2001 From: Alireza Mortezaeifar Date: Thu, 17 Jul 2025 00:20:47 +0330 Subject: [PATCH 4/5] Add space after quoted text in MessageContextMenu test --- .../components/views/context_menus/MessageContextMenu-test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 6d17d100b12..508850a5eef 100644 --- a/test/unit-tests/components/views/context_menus/MessageContextMenu-test.tsx +++ b/test/unit-tests/components/views/context_menus/MessageContextMenu-test.tsx @@ -414,7 +414,7 @@ describe("MessageContextMenu", () => { expect(dispatchSpy).toHaveBeenCalledWith( expect.objectContaining({ action: Action.ComposerInsert, - text: "> line1\n> line2", + text: "> line1\n> line2\n ", }), ); From 3a0cd3a1bd37c656029962b1d5b3b6b1d74feacd Mon Sep 17 00:00:00 2001 From: Alireza Mortezaeifar Date: Thu, 17 Jul 2025 01:56:29 +0330 Subject: [PATCH 5/5] add new line before and after the formated text --- src/components/views/context_menus/MessageContextMenu.tsx | 2 +- .../components/views/context_menus/MessageContextMenu-test.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/views/context_menus/MessageContextMenu.tsx b/src/components/views/context_menus/MessageContextMenu.tsx index 182778a3573..8d6f6cc6ebf 100644 --- a/src/components/views/context_menus/MessageContextMenu.tsx +++ b/src/components/views/context_menus/MessageContextMenu.tsx @@ -314,7 +314,7 @@ export default class MessageContextMenu extends React.Component .join("\n"); dis.dispatch({ action: Action.ComposerInsert, - text: quotedText + "\n ", + text: "\n" + quotedText + "\n\n ", timelineRenderingType: this.context.timelineRenderingType, }); } 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 508850a5eef..4c735bdd8ce 100644 --- a/test/unit-tests/components/views/context_menus/MessageContextMenu-test.tsx +++ b/test/unit-tests/components/views/context_menus/MessageContextMenu-test.tsx @@ -414,7 +414,7 @@ describe("MessageContextMenu", () => { expect(dispatchSpy).toHaveBeenCalledWith( expect.objectContaining({ action: Action.ComposerInsert, - text: "> line1\n> line2\n ", + text: "\n> line1\n> line2\n\n ", }), );