Skip to content

Commit 3e11a62

Browse files
authored
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
1 parent 084f447 commit 3e11a62

File tree

3 files changed

+282
-7
lines changed

3 files changed

+282
-7
lines changed

src/components/views/context_menus/MessageContextMenu.tsx

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,30 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
183183
);
184184
}
185185

186+
/**
187+
* Returns true if the current selection is entirely within a single "mx_MTextBody" element.
188+
*/
189+
private isSelectionWithinSingleTextBody(): boolean {
190+
const selection = window.getSelection();
191+
if (!selection || selection.rangeCount === 0) return false;
192+
const range = selection.getRangeAt(0);
193+
194+
function getParentByClass(node: Node | null, className: string): HTMLElement | null {
195+
while (node) {
196+
if (node instanceof HTMLElement && node.classList.contains(className)) {
197+
return node;
198+
}
199+
node = node.parentNode;
200+
}
201+
return null;
202+
}
203+
204+
const startTextBody = getParentByClass(range.startContainer, "mx_MTextBody");
205+
const endTextBody = getParentByClass(range.endContainer, "mx_MTextBody");
206+
207+
return !!startTextBody && startTextBody === endTextBody;
208+
}
209+
186210
private onResendReactionsClick = (): void => {
187211
for (const reaction of this.getUnsentReactions()) {
188212
Resend.resend(MatrixClientPeg.safeGet(), reaction);
@@ -279,6 +303,24 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
279303
this.closeMenu();
280304
};
281305

306+
private onQuoteClick = (): void => {
307+
const selectedText = getSelectedText();
308+
if (selectedText) {
309+
// Format as markdown quote
310+
const quotedText = selectedText
311+
.trim()
312+
.split(/\r?\n/)
313+
.map((line) => `> ${line}`)
314+
.join("\n");
315+
dis.dispatch({
316+
action: Action.ComposerInsert,
317+
text: "\n" + quotedText + "\n\n ",
318+
timelineRenderingType: this.context.timelineRenderingType,
319+
});
320+
}
321+
this.closeMenu();
322+
};
323+
282324
private onEditClick = (): void => {
283325
editEvent(
284326
MatrixClientPeg.safeGet(),
@@ -549,8 +591,10 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
549591
);
550592
}
551593

594+
const selectedText = getSelectedText();
595+
552596
let copyButton: JSX.Element | undefined;
553-
if (rightClick && getSelectedText()) {
597+
if (rightClick && selectedText) {
554598
copyButton = (
555599
<IconizedContextMenuOption
556600
iconClassName="mx_MessageContextMenu_iconCopy"
@@ -561,6 +605,18 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
561605
);
562606
}
563607

608+
let quoteButton: JSX.Element | undefined;
609+
if (rightClick && selectedText && selectedText.trim().length > 0 && this.isSelectionWithinSingleTextBody()) {
610+
quoteButton = (
611+
<IconizedContextMenuOption
612+
iconClassName="mx_MessageContextMenu_iconQuote"
613+
label={_t("action|quote")}
614+
triggerOnMouseDown={true}
615+
onClick={this.onQuoteClick}
616+
/>
617+
);
618+
}
619+
564620
let editButton: JSX.Element | undefined;
565621
if (rightClick && canEditContent(cli, mxEvent)) {
566622
editButton = (
@@ -630,10 +686,11 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
630686
}
631687

632688
let nativeItemsList: JSX.Element | undefined;
633-
if (copyButton || copyLinkButton) {
689+
if (copyButton || quoteButton || copyLinkButton) {
634690
nativeItemsList = (
635691
<IconizedContextMenuOptionList>
636692
{copyButton}
693+
{quoteButton}
637694
{copyLinkButton}
638695
</IconizedContextMenuOptionList>
639696
);

src/components/views/rooms/EventTile.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ import { getEventDisplayInfo } from "../../../utils/EventRenderingUtils";
6464
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
6565
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
6666
import { type ButtonEvent } from "../elements/AccessibleButton";
67-
import { copyPlaintext, getSelectedText } from "../../../utils/strings";
67+
import { copyPlaintext } from "../../../utils/strings";
6868
import { DecryptionFailureTracker } from "../../../DecryptionFailureTracker";
6969
import RedactedBody from "../messages/RedactedBody";
7070
import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
@@ -840,10 +840,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
840840
// Electron layer (webcontents-handler.ts)
841841
if (clickTarget instanceof HTMLImageElement) return;
842842

843-
// Return if we're in a browser and click either an a tag or we have
844-
// selected text, as in those cases we want to use the native browser
845-
// menu
846-
if (!PlatformPeg.get()?.allowOverridingNativeContextMenus() && (getSelectedText() || anchorElement)) return;
843+
// 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
844+
if (!PlatformPeg.get()?.allowOverridingNativeContextMenus() && anchorElement) return;
847845

848846
// We don't want to show the menu when editing a message
849847
if (this.props.editState) return;

test/unit-tests/components/views/context_menus/MessageContextMenu-test.tsx

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,226 @@ describe("MessageContextMenu", () => {
356356
});
357357
});
358358

359+
describe("quote button", () => {
360+
beforeEach(() => {
361+
jest.clearAllMocks();
362+
});
363+
364+
it("shows quote button when selection is inside one MTextBody and getSelectedText returns text", () => {
365+
mocked(getSelectedText).mockReturnValue("quoted text");
366+
const isSelectionWithinSingleTextBody = jest
367+
.spyOn(MessageContextMenu.prototype as any, "isSelectionWithinSingleTextBody")
368+
.mockReturnValue(true);
369+
370+
createRightClickMenuWithContent(createMessageEventContent("hello"));
371+
const quoteButton = document.querySelector('li[aria-label="Quote"]');
372+
expect(quoteButton).toBeTruthy();
373+
374+
isSelectionWithinSingleTextBody.mockRestore();
375+
});
376+
377+
it("does not show quote button when getSelectedText returns empty", () => {
378+
mocked(getSelectedText).mockReturnValue("");
379+
const isSelectionWithinSingleTextBody = jest
380+
.spyOn(MessageContextMenu.prototype as any, "isSelectionWithinSingleTextBody")
381+
.mockReturnValue(true);
382+
383+
createRightClickMenuWithContent(createMessageEventContent("hello"));
384+
const quoteButton = document.querySelector('li[aria-label="Quote"]');
385+
expect(quoteButton).toBeFalsy();
386+
387+
isSelectionWithinSingleTextBody.mockRestore();
388+
});
389+
390+
it("does not show quote button when selection is not inside one MTextBody", () => {
391+
mocked(getSelectedText).mockReturnValue("quoted text");
392+
const isSelectionWithinSingleTextBody = jest
393+
.spyOn(MessageContextMenu.prototype as any, "isSelectionWithinSingleTextBody")
394+
.mockReturnValue(false);
395+
396+
createRightClickMenuWithContent(createMessageEventContent("hello"));
397+
const quoteButton = document.querySelector('li[aria-label="Quote"]');
398+
expect(quoteButton).toBeFalsy();
399+
400+
isSelectionWithinSingleTextBody.mockRestore();
401+
});
402+
403+
it("dispatches ComposerInsert with quoted text when quote button is clicked", () => {
404+
mocked(getSelectedText).mockReturnValue("line1\nline2");
405+
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
406+
const isSelectionWithinSingleTextBody = jest
407+
.spyOn(MessageContextMenu.prototype as any, "isSelectionWithinSingleTextBody")
408+
.mockReturnValue(true);
409+
410+
createRightClickMenuWithContent(createMessageEventContent("hello"));
411+
const quoteButton = document.querySelector('li[aria-label="Quote"]')!;
412+
fireEvent.mouseDown(quoteButton);
413+
414+
expect(dispatchSpy).toHaveBeenCalledWith(
415+
expect.objectContaining({
416+
action: Action.ComposerInsert,
417+
text: "\n> line1\n> line2\n\n ",
418+
}),
419+
);
420+
421+
isSelectionWithinSingleTextBody.mockRestore();
422+
});
423+
424+
it("does not show quote button when getSelectedText returns only whitespace", () => {
425+
mocked(getSelectedText).mockReturnValue(" \n\t "); // whitespace only
426+
const isSelectionWithinSingleTextBody = jest
427+
.spyOn(MessageContextMenu.prototype as any, "isSelectionWithinSingleTextBody")
428+
.mockReturnValue(true);
429+
430+
createRightClickMenuWithContent(createMessageEventContent("hello"));
431+
const quoteButton = document.querySelector('li[aria-label="Quote"]');
432+
expect(quoteButton).toBeFalsy();
433+
434+
isSelectionWithinSingleTextBody.mockRestore();
435+
});
436+
});
437+
438+
describe("isSelectionWithinSingleTextBody", () => {
439+
let mockGetSelection: jest.SpyInstance;
440+
let contextMenuInstance: MessageContextMenu;
441+
442+
beforeEach(() => {
443+
jest.clearAllMocks();
444+
445+
mockGetSelection = jest.spyOn(window, "getSelection");
446+
447+
const eventContent = createMessageEventContent("hello");
448+
const mxEvent = new MatrixEvent({ type: EventType.RoomMessage, content: eventContent });
449+
450+
contextMenuInstance = new MessageContextMenu({
451+
mxEvent,
452+
onFinished: jest.fn(),
453+
rightClick: true,
454+
} as any);
455+
});
456+
457+
afterEach(() => {
458+
mockGetSelection.mockRestore();
459+
});
460+
461+
it("returns false when there is no selection", () => {
462+
mockGetSelection.mockReturnValue(null);
463+
464+
const result = (contextMenuInstance as any).isSelectionWithinSingleTextBody();
465+
expect(result).toBe(false);
466+
});
467+
468+
it("returns false when selection has no ranges", () => {
469+
mockGetSelection.mockReturnValue({
470+
rangeCount: 0,
471+
getRangeAt: jest.fn(),
472+
} as any);
473+
474+
const result = (contextMenuInstance as any).isSelectionWithinSingleTextBody();
475+
expect(result).toBe(false);
476+
});
477+
478+
it("returns true when selection is within a single mx_MTextBody element", () => {
479+
// Create a mock MTextBody element
480+
const textBodyElement = document.createElement("div");
481+
textBodyElement.classList.add("mx_MTextBody");
482+
483+
// Create mock text nodes within the MTextBody
484+
const startTextNode = document.createTextNode("start");
485+
const endTextNode = document.createTextNode("end");
486+
textBodyElement.appendChild(startTextNode);
487+
textBodyElement.appendChild(endTextNode);
488+
489+
// Create a mock range with the text nodes
490+
const mockRange = {
491+
startContainer: startTextNode,
492+
endContainer: endTextNode,
493+
} as unknown as Range;
494+
495+
mockGetSelection.mockReturnValue({
496+
rangeCount: 1,
497+
getRangeAt: jest.fn().mockReturnValue(mockRange),
498+
} as any);
499+
500+
const result = (contextMenuInstance as any).isSelectionWithinSingleTextBody();
501+
expect(result).toBe(true);
502+
});
503+
504+
it("returns false when selection spans multiple mx_MTextBody elements", () => {
505+
// Create two different MTextBody elements
506+
const textBody1 = document.createElement("div");
507+
textBody1.classList.add("mx_MTextBody");
508+
const textBody2 = document.createElement("div");
509+
textBody2.classList.add("mx_MTextBody");
510+
511+
const startTextNode = document.createTextNode("start");
512+
const endTextNode = document.createTextNode("end");
513+
textBody1.appendChild(startTextNode);
514+
textBody2.appendChild(endTextNode);
515+
516+
// Create a mock range spanning different MTextBody elements
517+
const mockRange = {
518+
startContainer: startTextNode,
519+
endContainer: endTextNode,
520+
} as unknown as Range;
521+
522+
mockGetSelection.mockReturnValue({
523+
rangeCount: 1,
524+
getRangeAt: jest.fn().mockReturnValue(mockRange),
525+
} as any);
526+
527+
const result = (contextMenuInstance as any).isSelectionWithinSingleTextBody();
528+
expect(result).toBe(false);
529+
});
530+
531+
it("returns false when selection is outside any mx_MTextBody element", () => {
532+
// Create regular div elements without mx_MTextBody class
533+
const regularDiv1 = document.createElement("div");
534+
const regularDiv2 = document.createElement("div");
535+
536+
const startTextNode = document.createTextNode("start");
537+
const endTextNode = document.createTextNode("end");
538+
regularDiv1.appendChild(startTextNode);
539+
regularDiv2.appendChild(endTextNode);
540+
541+
// Create a mock range outside MTextBody elements
542+
const mockRange = {
543+
startContainer: startTextNode,
544+
endContainer: endTextNode,
545+
} as unknown as Range;
546+
547+
mockGetSelection.mockReturnValue({
548+
rangeCount: 1,
549+
getRangeAt: jest.fn().mockReturnValue(mockRange),
550+
} as any);
551+
552+
const result = (contextMenuInstance as any).isSelectionWithinSingleTextBody();
553+
expect(result).toBe(false);
554+
});
555+
556+
it("returns true when start and end are the same mx_MTextBody element", () => {
557+
const textBodyElement = document.createElement("div");
558+
textBodyElement.classList.add("mx_MTextBody");
559+
560+
const textNode = document.createTextNode("same text");
561+
textBodyElement.appendChild(textNode);
562+
563+
// Create a mock range within the same MTextBody element
564+
const mockRange = {
565+
startContainer: textNode,
566+
endContainer: textNode,
567+
} as unknown as Range;
568+
569+
mockGetSelection.mockReturnValue({
570+
rangeCount: 1,
571+
getRangeAt: jest.fn().mockReturnValue(mockRange),
572+
} as any);
573+
574+
const result = (contextMenuInstance as any).isSelectionWithinSingleTextBody();
575+
expect(result).toBe(true);
576+
});
577+
});
578+
359579
describe("right click", () => {
360580
it("copy button does work as expected", () => {
361581
const text = "hello";

0 commit comments

Comments
 (0)