Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 59 additions & 2 deletions src/components/views/context_menus/MessageContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
for (const reaction of this.getUnsentReactions()) {
Resend.resend(MatrixClientPeg.safeGet(), reaction);
Expand Down Expand Up @@ -279,6 +303,24 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
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(),
Expand Down Expand Up @@ -549,8 +591,10 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
);
}

const selectedText = getSelectedText();

let copyButton: JSX.Element | undefined;
if (rightClick && getSelectedText()) {
if (rightClick && selectedText) {
copyButton = (
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconCopy"
Expand All @@ -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;
if (rightClick && canEditContent(cli, mxEvent)) {
editButton = (
Expand Down Expand Up @@ -630,10 +686,11 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
}

let nativeItemsList: JSX.Element | undefined;
if (copyButton || copyLinkButton) {
if (copyButton || quoteButton || copyLinkButton) {
nativeItemsList = (
<IconizedContextMenuOptionList>
{copyButton}
{quoteButton}
{copyLinkButton}
</IconizedContextMenuOptionList>
);
Expand Down
8 changes: 3 additions & 5 deletions src/components/views/rooms/EventTile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -840,10 +840,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
// 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading