/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { fireEvent, render, screen } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import { Room } from "matrix-js-sdk/src/matrix";
import { ReplacementEvent, RoomMessageEventContent } from "matrix-js-sdk/src/types";
import EditMessageComposerWithMatrixClient, {
createEditContent,
} from "../../../../../src/components/views/rooms/EditMessageComposer";
import EditorModel from "../../../../../src/editor/model";
import { createPartCreator } from "../../../editor/mock";
import {
getMockClientWithEventEmitter,
getRoomContext,
mkEvent,
mockClientMethodsUser,
setupRoomWithEventsTimeline,
} from "../../../../test-utils";
import DocumentOffset from "../../../../../src/editor/offset";
import SettingsStore from "../../../../../src/settings/SettingsStore";
import EditorStateTransfer from "../../../../../src/utils/EditorStateTransfer";
import RoomContext from "../../../../../src/contexts/RoomContext";
import { IRoomState } from "../../../../../src/components/structures/RoomView";
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
import Autocompleter, { IProviderCompletions } from "../../../../../src/autocomplete/Autocompleter";
import NotifProvider from "../../../../../src/autocomplete/NotifProvider";
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
describe("", () => {
const userId = "@alice:server.org";
const roomId = "!room:server.org";
const mockClient = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
getRoom: jest.fn(),
sendMessage: jest.fn(),
});
const room = new Room(roomId, mockClient, userId);
const editedEvent = mkEvent({
type: "m.room.message",
user: "@alice:test",
room: "!abc:test",
content: { body: "original message", msgtype: "m.text" },
event: true,
});
const eventWithMentions = mkEvent({
type: "m.room.message",
user: userId,
room: roomId,
content: {
"msgtype": "m.text",
"body": "hey Bob and Charlie",
"format": "org.matrix.custom.html",
"formatted_body":
'hey Bob and Charlie',
"m.mentions": {
user_ids: ["@bob:server.org", "@charlie:server.org"],
},
},
event: true,
});
// message composer emojipicker uses this
// which would require more irrelevant mocking
jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
const defaultRoomContext = getRoomContext(room, {});
const getComponent = (editState: EditorStateTransfer, roomContext: IRoomState = defaultRoomContext) =>
render(, {
wrapper: ({ children }) => (
{children}
),
});
beforeEach(() => {
mockClient.getRoom.mockReturnValue(room);
mockClient.sendMessage.mockClear();
userEvent.setup();
DMRoomMap.makeShared(mockClient);
jest.spyOn(Autocompleter.prototype, "getCompletions").mockResolvedValue([
{
completions: [
{
completion: "@dan:server.org",
completionId: "@dan:server.org",
type: "user",
suffix: " ",
component: Dan,
},
],
command: {
command: ["@d"],
},
provider: new NotifProvider(room),
} as unknown as IProviderCompletions,
]);
});
const editText = async (text: string, shouldClear?: boolean): Promise => {
const input = screen.getByRole("textbox");
if (shouldClear) {
await userEvent.clear(input);
}
await userEvent.type(input, text);
};
it("should edit a simple message", async () => {
const editState = new EditorStateTransfer(editedEvent);
getComponent(editState);
await editText(" + edit");
fireEvent.click(screen.getByText("Save"));
const expectedBody = {
...editedEvent.getContent(),
"body": " * original message + edit",
"m.new_content": {
"body": "original message + edit",
"msgtype": "m.text",
"m.mentions": {},
},
"m.relates_to": {
event_id: editedEvent.getId(),
rel_type: "m.replace",
},
"m.mentions": {},
};
expect(mockClient.sendMessage).toHaveBeenCalledWith(editedEvent.getRoomId()!, null, expectedBody);
});
it("should throw when room for message is not found", () => {
mockClient.getRoom.mockReturnValue(null);
const editState = new EditorStateTransfer(editedEvent);
expect(() => getComponent(editState, { ...defaultRoomContext, room: undefined })).toThrow(
"Cannot render without room",
);
});
describe("createEditContent", () => {
it("sends plaintext messages correctly", () => {
const model = new EditorModel([], createPartCreator());
const documentOffset = new DocumentOffset(11, true);
model.update("hello world", "insertText", documentOffset);
const content = createEditContent(model, editedEvent);
expect(content).toEqual({
"body": " * hello world",
"msgtype": "m.text",
"m.new_content": {
"body": "hello world",
"msgtype": "m.text",
"m.mentions": {},
},
"m.relates_to": {
event_id: editedEvent.getId(),
rel_type: "m.replace",
},
"m.mentions": {},
});
});
it("sends markdown messages correctly", () => {
const model = new EditorModel([], createPartCreator());
const documentOffset = new DocumentOffset(13, true);
model.update("hello *world*", "insertText", documentOffset);
const content = createEditContent(model, editedEvent);
expect(content).toEqual({
"body": " * hello *world*",
"msgtype": "m.text",
"format": "org.matrix.custom.html",
"formatted_body": " * hello world",
"m.new_content": {
"body": "hello *world*",
"msgtype": "m.text",
"format": "org.matrix.custom.html",
"formatted_body": "hello world",
"m.mentions": {},
},
"m.relates_to": {
event_id: editedEvent.getId(),
rel_type: "m.replace",
},
"m.mentions": {},
});
});
it("strips /me from messages and marks them as m.emote accordingly", () => {
const model = new EditorModel([], createPartCreator());
const documentOffset = new DocumentOffset(22, true);
model.update("/me blinks __quickly__", "insertText", documentOffset);
const content = createEditContent(model, editedEvent);
expect(content).toEqual({
"body": " * blinks __quickly__",
"msgtype": "m.emote",
"format": "org.matrix.custom.html",
"formatted_body": " * blinks quickly",
"m.new_content": {
"body": "blinks __quickly__",
"msgtype": "m.emote",
"format": "org.matrix.custom.html",
"formatted_body": "blinks quickly",
"m.mentions": {},
},
"m.relates_to": {
event_id: editedEvent.getId(),
rel_type: "m.replace",
},
"m.mentions": {},
});
});
it("allows emoting with non-text parts", () => {
const model = new EditorModel([], createPartCreator());
const documentOffset = new DocumentOffset(16, true);
model.update("/me ✨sparkles✨", "insertText", documentOffset);
expect(model.parts.length).toEqual(4); // Emoji count as non-text
const content = createEditContent(model, editedEvent);
expect(content).toEqual({
"body": " * ✨sparkles✨",
"msgtype": "m.emote",
"m.new_content": {
"body": "✨sparkles✨",
"msgtype": "m.emote",
"m.mentions": {},
},
"m.relates_to": {
event_id: editedEvent.getId(),
rel_type: "m.replace",
},
"m.mentions": {},
});
});
it("allows sending double-slash escaped slash commands correctly", () => {
const model = new EditorModel([], createPartCreator());
const documentOffset = new DocumentOffset(32, true);
model.update("//dev/null is my favourite place", "insertText", documentOffset);
const content = createEditContent(model, editedEvent);
// TODO Edits do not properly strip the double slash used to skip
// command processing.
expect(content).toEqual({
"body": " * //dev/null is my favourite place",
"msgtype": "m.text",
"m.new_content": {
"body": "//dev/null is my favourite place",
"msgtype": "m.text",
"m.mentions": {},
},
"m.relates_to": {
event_id: editedEvent.getId(),
rel_type: "m.replace",
},
"m.mentions": {},
});
});
});
describe("when message is not a reply", () => {
it("should attach an empty mentions object for a message with no mentions", async () => {
const editState = new EditorStateTransfer(editedEvent);
getComponent(editState);
const editContent = " + edit";
await editText(editContent);
fireEvent.click(screen.getByText("Save"));
const messageContent = mockClient.sendMessage.mock.calls[0][2] as RoomMessageEventContent &
ReplacementEvent;
// both content.mentions and new_content.mentions are empty
expect(messageContent["m.mentions"]).toEqual({});
expect(messageContent["m.new_content"]!["m.mentions"]).toEqual({});
});
it("should retain mentions in the original message that are not removed by the edit", async () => {
const editState = new EditorStateTransfer(eventWithMentions);
getComponent(editState);
// Remove charlie from the message
const editContent = "{backspace}{backspace}friends";
await editText(editContent);
fireEvent.click(screen.getByText("Save"));
const messageContent = mockClient.sendMessage.mock.calls[0][2] as RoomMessageEventContent &
ReplacementEvent;
// no new mentions were added, so nothing in top level mentions
expect(messageContent["m.mentions"]).toEqual({});
// bob is still mentioned, charlie removed
expect(messageContent["m.new_content"]!["m.mentions"]).toEqual({
user_ids: ["@bob:server.org"],
});
});
it("should remove mentions that are removed by the edit", async () => {
const editState = new EditorStateTransfer(eventWithMentions);
getComponent(editState);
const editContent = "new message!";
// clear the original message
await editText(editContent, true);
fireEvent.click(screen.getByText("Save"));
const messageContent = mockClient.sendMessage.mock.calls[0][2] as RoomMessageEventContent &
ReplacementEvent;
// no new mentions were added, so nothing in top level mentions
expect(messageContent["m.mentions"]).toEqual({});
// bob is not longer mentioned in the edited message, so empty mentions in new_content
expect(messageContent["m.new_content"]!["m.mentions"]).toEqual({});
});
it("should add mentions that were added in the edit", async () => {
const editState = new EditorStateTransfer(editedEvent);
getComponent(editState);
const editContent = " and @d";
await editText(editContent);
// wait for autocompletion to render
await screen.findByText("Dan");
// submit autocomplete for mention
await editText("{enter}");
fireEvent.click(screen.getByText("Save"));
const messageContent = mockClient.sendMessage.mock.calls[0][2] as RoomMessageEventContent &
ReplacementEvent;
// new mention in the edit
expect(messageContent["m.mentions"]).toEqual({
user_ids: ["@dan:server.org"],
});
expect(messageContent["m.new_content"]!["m.mentions"]).toEqual({
user_ids: ["@dan:server.org"],
});
});
it("should add and remove mentions from the edit", async () => {
const editState = new EditorStateTransfer(eventWithMentions);
getComponent(editState);
// Remove charlie from the message
await editText("{backspace}{backspace}");
// and replace with @room
await editText("@d");
// wait for autocompletion to render
await screen.findByText("Dan");
// submit autocomplete for @dan mention
await editText("{enter}");
fireEvent.click(screen.getByText("Save"));
const messageContent = mockClient.sendMessage.mock.calls[0][2] as RoomMessageEventContent &
ReplacementEvent;
// new mention in the edit
expect(messageContent["m.mentions"]).toEqual({
user_ids: ["@dan:server.org"],
});
// all mentions in the edited version of the event
expect(messageContent["m.new_content"]!["m.mentions"]).toEqual({
user_ids: ["@bob:server.org", "@dan:server.org"],
});
});
});
describe("when message is replying", () => {
const originalEvent = mkEvent({
type: "m.room.message",
user: "@ernie:test",
room: roomId,
content: { body: "original message", msgtype: "m.text" },
event: true,
});
const replyEvent = mkEvent({
type: "m.room.message",
user: "@bert:test",
room: roomId,
content: {
"body": "reply with plain message",
"msgtype": "m.text",
"m.relates_to": {
"m.in_reply_to": {
event_id: originalEvent.getId(),
},
},
"m.mentions": {
user_ids: [originalEvent.getSender()!],
},
},
event: true,
});
const replyWithMentions = mkEvent({
type: "m.room.message",
user: "@bert:test",
room: roomId,
content: {
"body": 'reply that mentions Bob',
"msgtype": "m.text",
"m.relates_to": {
"m.in_reply_to": {
event_id: originalEvent.getId(),
},
},
"m.mentions": {
user_ids: [
// sender of event we replied to
originalEvent.getSender()!,
// mentions from this event
"@bob:server.org",
],
},
},
event: true,
});
beforeEach(() => {
setupRoomWithEventsTimeline(room, [originalEvent, replyEvent]);
});
it("should retain parent event sender in mentions when editing with plain text", async () => {
const editState = new EditorStateTransfer(replyEvent);
getComponent(editState);
const editContent = " + edit";
await editText(editContent);
fireEvent.click(screen.getByText("Save"));
const messageContent = mockClient.sendMessage.mock.calls[0][2] as RoomMessageEventContent &
ReplacementEvent;
// no new mentions from edit
expect(messageContent["m.mentions"]).toEqual({});
// edited reply still mentions the parent event sender
expect(messageContent["m.new_content"]!["m.mentions"]).toEqual({
user_ids: [originalEvent.getSender()],
});
});
it("should retain parent event sender in mentions when adding a mention", async () => {
const editState = new EditorStateTransfer(replyEvent);
getComponent(editState);
await editText(" and @d");
// wait for autocompletion to render
await screen.findByText("Dan");
// submit autocomplete for @dan mention
await editText("{enter}");
fireEvent.click(screen.getByText("Save"));
const messageContent = mockClient.sendMessage.mock.calls[0][2] as RoomMessageEventContent &
ReplacementEvent;
// new mention in edit
expect(messageContent["m.mentions"]).toEqual({
user_ids: ["@dan:server.org"],
});
// edited reply still mentions the parent event sender
// plus new mention @dan
expect(messageContent["m.new_content"]!["m.mentions"]).toEqual({
user_ids: [originalEvent.getSender(), "@dan:server.org"],
});
});
it("should retain parent event sender in mentions when removing all mentions from content", async () => {
const editState = new EditorStateTransfer(replyWithMentions);
getComponent(editState);
// replace text to remove all mentions
await editText("no mentions here", true);
fireEvent.click(screen.getByText("Save"));
const messageContent = mockClient.sendMessage.mock.calls[0][2] as RoomMessageEventContent &
ReplacementEvent;
// no mentions in edit
expect(messageContent["m.mentions"]).toEqual({});
// edited reply still mentions the parent event sender
// existing @bob mention removed
expect(messageContent["m.new_content"]!["m.mentions"]).toEqual({
user_ids: [originalEvent.getSender()],
});
});
it("should retain parent event sender in mentions when removing mention of said user", async () => {
const replyThatMentionsParentEventSender = mkEvent({
type: "m.room.message",
user: "@bert:test",
room: roomId,
content: {
"body": `reply that mentions the sender of the message we replied to Ernie`,
"msgtype": "m.text",
"m.relates_to": {
"m.in_reply_to": {
event_id: originalEvent.getId(),
},
},
"m.mentions": {
user_ids: [
// sender of event we replied to
originalEvent.getSender()!,
],
},
},
event: true,
});
const editState = new EditorStateTransfer(replyThatMentionsParentEventSender);
getComponent(editState);
// replace text to remove all mentions
await editText("no mentions here", true);
fireEvent.click(screen.getByText("Save"));
const messageContent = mockClient.sendMessage.mock.calls[0][2] as RoomMessageEventContent &
ReplacementEvent;
// no mentions in edit
expect(messageContent["m.mentions"]).toEqual({});
// edited reply still mentions the parent event sender
expect(messageContent["m.new_content"]!["m.mentions"]).toEqual({
user_ids: [originalEvent.getSender()],
});
});
});
});