riot-web/test/components/views/rooms/EditMessageComposer-test.tsx

550 lines
21 KiB
TypeScript
Raw Normal View History

/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Room } from "matrix-js-sdk/src/matrix";
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("<EditMessageComposer/>", () => {
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 <a href="https://matrix.to/#/@bob:server.org">Bob</a> and <a href="https://matrix.to/#/@charlie:server.org">Charlie</a>',
"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(<EditMessageComposerWithMatrixClient editState={editState} />, {
wrapper: ({ children }) => (
<MatrixClientContext.Provider value={mockClient}>
<RoomContext.Provider value={roomContext}>{children}</RoomContext.Provider>
</MatrixClientContext.Provider>
),
});
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: <span>Dan</span>,
},
],
command: {
command: ["@d"],
},
provider: new NotifProvider(room),
} as unknown as IProviderCompletions,
]);
});
const editText = async (text: string, shouldClear?: boolean): Promise<void> => {
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 <em>world</em>",
"m.new_content": {
"body": "hello *world*",
"msgtype": "m.text",
"format": "org.matrix.custom.html",
"formatted_body": "hello <em>world</em>",
"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 <strong>quickly</strong>",
"m.new_content": {
"body": "blinks __quickly__",
"msgtype": "m.emote",
"format": "org.matrix.custom.html",
"formatted_body": "blinks <strong>quickly</strong>",
"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];
// 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];
// 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];
// 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];
// 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];
// 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 <a href="https://matrix.to/#/@bob:server.org">Bob</a>',
"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];
// 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];
// 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];
// 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 <a href="https://matrix.to/#/${originalEvent.getSender()!}">Ernie</a>`,
"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];
// 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()],
});
});
});
});