Add quote functionality to MessageContextMenu (#29893) (#30323)

* Add quote functionality to MessageContextMenu (#29893)

* Remove unused import of getSelectedText from strings utility in EventTile component

* Add space after quoted text in ComposerInsert action

* Add space after quoted text in MessageContextMenu test

* add new line before and after the formated text
This commit is contained in:
AlirezaMrtz 2025-07-17 13:15:08 +03:30 committed by GitHub
parent 084f447c6e
commit 3e11a62a3f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 282 additions and 7 deletions

View File

@ -183,6 +183,30 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
); );
} }
/**
* 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 => { private onResendReactionsClick = (): void => {
for (const reaction of this.getUnsentReactions()) { for (const reaction of this.getUnsentReactions()) {
Resend.resend(MatrixClientPeg.safeGet(), reaction); Resend.resend(MatrixClientPeg.safeGet(), reaction);
@ -279,6 +303,24 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
this.closeMenu(); 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 => { private onEditClick = (): void => {
editEvent( editEvent(
MatrixClientPeg.safeGet(), MatrixClientPeg.safeGet(),
@ -549,8 +591,10 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
); );
} }
const selectedText = getSelectedText();
let copyButton: JSX.Element | undefined; let copyButton: JSX.Element | undefined;
if (rightClick && getSelectedText()) { if (rightClick && selectedText) {
copyButton = ( copyButton = (
<IconizedContextMenuOption <IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconCopy" iconClassName="mx_MessageContextMenu_iconCopy"
@ -561,6 +605,18 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
); );
} }
let quoteButton: JSX.Element | undefined;
if (rightClick && selectedText && selectedText.trim().length > 0 && this.isSelectionWithinSingleTextBody()) {
quoteButton = (
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconQuote"
label={_t("action|quote")}
triggerOnMouseDown={true}
onClick={this.onQuoteClick}
/>
);
}
let editButton: JSX.Element | undefined; let editButton: JSX.Element | undefined;
if (rightClick && canEditContent(cli, mxEvent)) { if (rightClick && canEditContent(cli, mxEvent)) {
editButton = ( editButton = (
@ -630,10 +686,11 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
} }
let nativeItemsList: JSX.Element | undefined; let nativeItemsList: JSX.Element | undefined;
if (copyButton || copyLinkButton) { if (copyButton || quoteButton || copyLinkButton) {
nativeItemsList = ( nativeItemsList = (
<IconizedContextMenuOptionList> <IconizedContextMenuOptionList>
{copyButton} {copyButton}
{quoteButton}
{copyLinkButton} {copyLinkButton}
</IconizedContextMenuOptionList> </IconizedContextMenuOptionList>
); );

View File

@ -64,7 +64,7 @@ import { getEventDisplayInfo } from "../../../utils/EventRenderingUtils";
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
import { MediaEventHelper } from "../../../utils/MediaEventHelper"; import { MediaEventHelper } from "../../../utils/MediaEventHelper";
import { type ButtonEvent } from "../elements/AccessibleButton"; import { type ButtonEvent } from "../elements/AccessibleButton";
import { copyPlaintext, getSelectedText } from "../../../utils/strings"; import { copyPlaintext } from "../../../utils/strings";
import { DecryptionFailureTracker } from "../../../DecryptionFailureTracker"; import { DecryptionFailureTracker } from "../../../DecryptionFailureTracker";
import RedactedBody from "../messages/RedactedBody"; import RedactedBody from "../messages/RedactedBody";
import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
@ -840,10 +840,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
// Electron layer (webcontents-handler.ts) // Electron layer (webcontents-handler.ts)
if (clickTarget instanceof HTMLImageElement) return; if (clickTarget instanceof HTMLImageElement) return;
// Return if we're in a browser and click either an a tag or we have // 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
// selected text, as in those cases we want to use the native browser if (!PlatformPeg.get()?.allowOverridingNativeContextMenus() && anchorElement) return;
// menu
if (!PlatformPeg.get()?.allowOverridingNativeContextMenus() && (getSelectedText() || anchorElement)) return;
// We don't want to show the menu when editing a message // We don't want to show the menu when editing a message
if (this.props.editState) return; if (this.props.editState) return;

View File

@ -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", () => { describe("right click", () => {
it("copy button does work as expected", () => { it("copy button does work as expected", () => {
const text = "hello"; const text = "hello";