riot-web/test/unit-tests/components/views/rooms/wysiwyg_composer/utils/message-test.ts

467 lines
19 KiB
TypeScript

/*
Copyright 2024 New Vector Ltd.
Copyright 2022 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 { EventStatus, IEventRelation, MsgType } from "matrix-js-sdk/src/matrix";
import { IRoomState } from "../../../../../../../src/components/structures/RoomView";
import {
editMessage,
sendMessage,
} from "../../../../../../../src/components/views/rooms/wysiwyg_composer/utils/message";
import { createTestClient, getRoomContext, mkEvent, mkStubRoom } from "../../../../../../test-utils";
import defaultDispatcher from "../../../../../../../src/dispatcher/dispatcher";
import SettingsStore from "../../../../../../../src/settings/SettingsStore";
import { SettingLevel } from "../../../../../../../src/settings/SettingLevel";
import { RoomPermalinkCreator } from "../../../../../../../src/utils/permalinks/Permalinks";
import EditorStateTransfer from "../../../../../../../src/utils/EditorStateTransfer";
import * as ConfirmRedactDialog from "../../../../../../../src/components/views/dialogs/ConfirmRedactDialog";
import * as SlashCommands from "../../../../../../../src/SlashCommands";
import * as Commands from "../../../../../../../src/editor/commands";
import * as Reply from "../../../../../../../src/utils/Reply";
import { MatrixClientPeg } from "../../../../../../../src/MatrixClientPeg";
import { Action } from "../../../../../../../src/dispatcher/actions";
describe("message", () => {
const permalinkCreator = {
forEvent(eventId: string): string {
return "$$permalink$$";
},
} as RoomPermalinkCreator;
const message = "<i><b>hello</b> world</i>";
const mockEvent = mkEvent({
type: "m.room.message",
room: "myfakeroom",
user: "myfakeuser",
content: {
msgtype: "m.text",
body: "Replying to this",
format: "org.matrix.custom.html",
formatted_body: "Replying to this",
},
event: true,
});
const mockClient = createTestClient();
mockClient.setDisplayName = jest.fn().mockResolvedValue({});
mockClient.setRoomName = jest.fn().mockResolvedValue({});
const mockRoom = mkStubRoom("myfakeroom", "myfakeroom", mockClient) as any;
mockRoom.findEventById = jest.fn((eventId) => {
return eventId === mockEvent.getId() ? mockEvent : null;
});
const defaultRoomContext: IRoomState = getRoomContext(mockRoom, {});
const spyDispatcher = jest.spyOn(defaultDispatcher, "dispatch");
beforeEach(() => {
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
jest.clearAllMocks();
});
afterAll(() => {
jest.restoreAllMocks();
});
describe("sendMessage", () => {
it("Should not send empty html message", async () => {
// When
await sendMessage("", true, { roomContext: defaultRoomContext, mxClient: mockClient, permalinkCreator });
// Then
expect(mockClient.sendMessage).toHaveBeenCalledTimes(0);
expect(spyDispatcher).toHaveBeenCalledTimes(0);
});
it("Should not send message when there is no roomId", async () => {
// When
const mockRoomWithoutId = mkStubRoom("", "room without id", mockClient) as any;
const mockRoomContextWithoutId: IRoomState = getRoomContext(mockRoomWithoutId, {});
await sendMessage(message, true, {
roomContext: mockRoomContextWithoutId,
mxClient: mockClient,
permalinkCreator,
});
// Then
expect(mockClient.sendMessage).toHaveBeenCalledTimes(0);
expect(spyDispatcher).toHaveBeenCalledTimes(0);
});
describe("calls client.sendMessage with", () => {
it("a null argument if SendMessageParams is missing relation", async () => {
// When
await sendMessage(message, true, {
roomContext: defaultRoomContext,
mxClient: mockClient,
permalinkCreator,
});
// Then
expect(mockClient.sendMessage).toHaveBeenCalledWith(expect.anything(), null, expect.anything());
});
it("a null argument if SendMessageParams has relation but relation is missing event_id", async () => {
// When
await sendMessage(message, true, {
roomContext: defaultRoomContext,
mxClient: mockClient,
permalinkCreator,
relation: {},
});
// Then
expect(mockClient.sendMessage).toHaveBeenCalledWith(expect.anything(), null, expect.anything());
});
it("a null argument if SendMessageParams has relation but rel_type does not match THREAD_RELATION_TYPE.name", async () => {
// When
await sendMessage(message, true, {
roomContext: defaultRoomContext,
mxClient: mockClient,
permalinkCreator,
relation: {
event_id: "valid_id",
rel_type: "m.does_not_match",
},
});
// Then
expect(mockClient.sendMessage).toHaveBeenCalledWith(expect.anything(), null, expect.anything());
});
it("the event_id if SendMessageParams has relation and rel_type matches THREAD_RELATION_TYPE.name", async () => {
// When
await sendMessage(message, true, {
roomContext: defaultRoomContext,
mxClient: mockClient,
permalinkCreator,
relation: {
event_id: "valid_id",
rel_type: "m.thread",
},
});
// Then
expect(mockClient.sendMessage).toHaveBeenCalledWith(expect.anything(), "valid_id", expect.anything());
});
});
it("Should send html message", async () => {
// When
await sendMessage(message, true, {
roomContext: defaultRoomContext,
mxClient: mockClient,
permalinkCreator,
});
// Then
const expectedContent = {
body: "*__hello__ world*",
format: "org.matrix.custom.html",
formatted_body: "<i><b>hello</b> world</i>",
msgtype: "m.text",
};
expect(mockClient.sendMessage).toHaveBeenCalledWith("myfakeroom", null, expectedContent);
expect(spyDispatcher).toHaveBeenCalledWith({ action: "message_sent" });
});
it("Should send reply to html message", async () => {
const mockReplyEvent = mkEvent({
type: "m.room.message",
room: "myfakeroom",
user: "myfakeuser2",
content: { msgtype: "m.text", body: "My reply" },
event: true,
});
// When
await sendMessage(message, true, {
roomContext: defaultRoomContext,
mxClient: mockClient,
permalinkCreator,
replyToEvent: mockReplyEvent,
});
// Then
expect(spyDispatcher).toHaveBeenCalledWith({
action: "reply_to_event",
event: null,
context: defaultRoomContext.timelineRenderingType,
});
const expectedContent = {
"body": "> <myfakeuser2> My reply\n\n*__hello__ world*",
"format": "org.matrix.custom.html",
"formatted_body":
'<mx-reply><blockquote><a href="$$permalink$$">In reply to</a>' +
' <a href="https://matrix.to/#/myfakeuser2">myfakeuser2</a>' +
"<br>My reply</blockquote></mx-reply><i><b>hello</b> world</i>",
"msgtype": "m.text",
"m.relates_to": {
"m.in_reply_to": {
event_id: mockReplyEvent.getId(),
},
},
};
expect(mockClient.sendMessage).toHaveBeenCalledWith("myfakeroom", null, expectedContent);
});
it("Should scroll to bottom after sending a html message", async () => {
// When
SettingsStore.setValue("scrollToBottomOnMessageSent", null, SettingLevel.DEVICE, true);
await sendMessage(message, true, {
roomContext: defaultRoomContext,
mxClient: mockClient,
permalinkCreator,
});
// Then
expect(spyDispatcher).toHaveBeenCalledWith({
action: "scroll_to_bottom",
timelineRenderingType: defaultRoomContext.timelineRenderingType,
});
});
it("Should handle emojis", async () => {
// When
await sendMessage("🎉", false, { roomContext: defaultRoomContext, mxClient: mockClient, permalinkCreator });
// Then
expect(spyDispatcher).toHaveBeenCalledWith({ action: "effects.confetti" });
});
describe("slash commands", () => {
const getCommandSpy = jest.spyOn(SlashCommands, "getCommand");
it("calls getCommand for a message starting with a valid command", async () => {
// When
const validCommand = "/spoiler";
await sendMessage(validCommand, true, {
roomContext: defaultRoomContext,
mxClient: mockClient,
permalinkCreator,
});
// Then
expect(getCommandSpy).toHaveBeenCalledWith(validCommand);
});
it("does not call getCommand for valid command with invalid prefix", async () => {
// When
const invalidPrefixCommand = "//spoiler";
await sendMessage(invalidPrefixCommand, true, {
roomContext: defaultRoomContext,
mxClient: mockClient,
permalinkCreator,
});
// Then
expect(getCommandSpy).toHaveBeenCalledTimes(0);
});
it("returns undefined when the command is not successful", async () => {
// When
const validCommand = "/spoiler";
jest.spyOn(Commands, "runSlashCommand").mockResolvedValueOnce([
{ body: "mock content", msgtype: MsgType.Text },
false,
]);
const result = await sendMessage(validCommand, true, {
roomContext: defaultRoomContext,
mxClient: mockClient,
permalinkCreator,
});
// Then
expect(result).toBeUndefined();
});
// /spoiler is a .messages category command, /fireworks is an .effect category command
const messagesAndEffectCategoryTestCases = ["/spoiler text", "/fireworks"];
it.each(messagesAndEffectCategoryTestCases)(
"does not add relations for a .messages or .effects category command if there is no relation to add",
async (inputText) => {
await sendMessage(inputText, true, {
roomContext: defaultRoomContext,
mxClient: mockClient,
permalinkCreator,
});
expect(mockClient.sendMessage).toHaveBeenCalledWith(
"myfakeroom",
null,
expect.not.objectContaining({ "m.relates_to": expect.any }),
);
},
);
it.each(messagesAndEffectCategoryTestCases)(
"adds relations for a .messages or .effects category command if there is a relation",
async (inputText) => {
const mockRelation: IEventRelation = {
rel_type: "mock relation type",
};
await sendMessage(inputText, true, {
roomContext: defaultRoomContext,
mxClient: mockClient,
permalinkCreator,
relation: mockRelation,
});
expect(mockClient.sendMessage).toHaveBeenCalledWith(
"myfakeroom",
null,
expect.objectContaining({ "m.relates_to": expect.objectContaining(mockRelation) }),
);
},
);
it("calls addReplyToMessageContent when there is an event to reply to", async () => {
const addReplySpy = jest.spyOn(Reply, "addReplyToMessageContent");
await sendMessage("input", true, {
roomContext: defaultRoomContext,
mxClient: mockClient,
permalinkCreator,
replyToEvent: mockEvent,
});
expect(addReplySpy).toHaveBeenCalledTimes(1);
});
// these test cases are .action and .admin categories
const otherCategoryTestCases = ["/nick new_nickname", "/roomname new_room_name"];
it.each(otherCategoryTestCases)(
"returns undefined when the command category is not .messages or .effects",
async (input) => {
const result = await sendMessage(input, true, {
roomContext: defaultRoomContext,
mxClient: mockClient,
permalinkCreator,
replyToEvent: mockEvent,
});
expect(result).toBeUndefined();
},
);
it("if user enters invalid command and then sends it anyway", async () => {
// mock out returning a true value for `shouldSendAnyway` to avoid rendering the modal
jest.spyOn(Commands, "shouldSendAnyway").mockResolvedValueOnce(true);
const invalidCommandInput = "/badCommand";
await sendMessage(invalidCommandInput, true, {
roomContext: defaultRoomContext,
mxClient: mockClient,
permalinkCreator,
});
// we expect the message to have been sent
// and a composer focus action to have been dispatched
expect(mockClient.sendMessage).toHaveBeenCalledWith(
"myfakeroom",
null,
expect.objectContaining({ body: invalidCommandInput }),
);
expect(spyDispatcher).toHaveBeenCalledWith(expect.objectContaining({ action: Action.FocusAComposer }));
});
it("if user enters invalid command and then does not send, return undefined", async () => {
// mock out returning a false value for `shouldSendAnyway` to avoid rendering the modal
jest.spyOn(Commands, "shouldSendAnyway").mockResolvedValueOnce(false);
const invalidCommandInput = "/badCommand";
const result = await sendMessage(invalidCommandInput, true, {
roomContext: defaultRoomContext,
mxClient: mockClient,
permalinkCreator,
});
expect(result).toBeUndefined();
});
});
});
describe("editMessage", () => {
const editorStateTransfer = new EditorStateTransfer(mockEvent);
it("Should cancel editing and ask for event removal when message is empty", async () => {
// When
const mockCreateRedactEventDialog = jest.spyOn(ConfirmRedactDialog, "createRedactEventDialog");
const mockEvent = mkEvent({
type: "m.room.message",
room: "myfakeroom",
user: "myfakeuser",
content: { msgtype: "m.text", body: "Replying to this" },
event: true,
});
const replacingEvent = mkEvent({
type: "m.room.message",
room: "myfakeroom",
user: "myfakeuser",
content: { msgtype: "m.text", body: "ReplacingEvent" },
event: true,
});
replacingEvent.setStatus(EventStatus.QUEUED);
mockEvent.makeReplaced(replacingEvent);
const editorStateTransfer = new EditorStateTransfer(mockEvent);
await editMessage("", { roomContext: defaultRoomContext, mxClient: mockClient, editorStateTransfer });
// Then
expect(mockClient.sendMessage).toHaveBeenCalledTimes(0);
expect(mockClient.cancelPendingEvent).toHaveBeenCalledTimes(1);
expect(mockCreateRedactEventDialog).toHaveBeenCalledTimes(1);
expect(spyDispatcher).toHaveBeenCalledTimes(0);
});
it("Should do nothing if the content is unmodified", async () => {
// When
await editMessage(mockEvent.getContent().body, {
roomContext: defaultRoomContext,
mxClient: mockClient,
editorStateTransfer,
});
// Then
expect(mockClient.sendMessage).toHaveBeenCalledTimes(0);
});
it("Should send a message when the content is modified", async () => {
// When
const newMessage = `${mockEvent.getContent().body} new content`;
await editMessage(newMessage, {
roomContext: defaultRoomContext,
mxClient: mockClient,
editorStateTransfer,
});
// Then
const { msgtype, format } = mockEvent.getContent();
const expectedContent = {
"body": ` * ${newMessage}`,
"formatted_body": ` * ${newMessage}`,
"m.new_content": {
body: "Replying to this new content",
format: "org.matrix.custom.html",
formatted_body: "Replying to this new content",
msgtype: "m.text",
},
"m.relates_to": {
event_id: mockEvent.getId(),
rel_type: "m.replace",
},
msgtype,
format,
};
expect(mockClient.sendMessage).toHaveBeenCalledWith(mockEvent.getRoomId(), null, expectedContent);
expect(spyDispatcher).toHaveBeenCalledWith({ action: "message_sent" });
});
});
});