mirror of
https://github.com/vector-im/element-web.git
synced 2025-08-07 14:57:08 +02:00
* 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:
parent
084f447c6e
commit
3e11a62a3f
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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;
|
||||||
|
@ -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";
|
||||||
|
Loading…
Reference in New Issue
Block a user