Files

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

490 lines
20 KiB
TypeScript
Raw Permalink Normal View History

2020-01-22 11:56:27 +00:00
/*
2024-09-09 14:57:16 +01:00
Copyright 2024 New Vector Ltd.
2020-01-22 11:56:27 +00:00
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
2024-09-09 14:57:16 +01:00
Please see LICENSE files in the repository root for full details.
2020-01-22 11:56:27 +00:00
*/
import React from "react";
import { fireEvent, render, waitFor } from "jest-matrix-react";
import { type MatrixClient, MsgType } from "matrix-js-sdk/src/matrix";
2022-07-13 07:56:36 +02:00
import { mocked } from "jest-mock";
import userEvent from "@testing-library/user-event";
2020-11-17 17:36:58 -05:00
import SendMessageComposer, {
createMessageContent,
isQuickReaction,
2024-10-15 14:57:26 +01:00
} from "../../../../../src/components/views/rooms/SendMessageComposer";
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
2026-07-10 10:47:41 +01:00
import {
type RoomContextType,
TimelineRenderingType,
MainSplitContentType,
} from "../../../../../src/contexts/RoomContext";
2024-10-15 14:57:26 +01:00
import EditorModel from "../../../../../src/editor/model";
import { createPartCreator } from "../../../editor/mock";
2024-10-15 14:57:26 +01:00
import { createTestClient, mkEvent, mkStubRoom, stubClient } from "../../../../test-utils";
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import defaultDispatcher from "../../../../../src/dispatcher/dispatcher";
import DocumentOffset from "../../../../../src/editor/offset";
import { Layout } from "../../../../../src/settings/enums/Layout";
import { mockPlatformPeg } from "../../../../test-utils/platform";
import { doMaybeLocalRoomAction } from "../../../../../src/utils/local-room";
import { addTextToComposer } from "../../../../test-utils/composer";
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
2026-07-01 13:49:18 +01:00
import { SDKContextClass } from "../../../../../src/contexts/SDKContextClass";
import { RoomUploadContextProvider } from "../../../../../src/viewmodels/room/RoomUploadViewModel.tsx";
import { MessageComposerUrlPreviewViewModel } from "../../../../../src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts";
import { SDKContext } from "../../../../../src/contexts/SDKContext.ts";
2022-07-13 07:56:36 +02:00
2024-10-15 14:57:26 +01:00
jest.mock("../../../../../src/utils/local-room", () => ({
2022-07-13 07:56:36 +02:00
doMaybeLocalRoomAction: jest.fn(),
}));
2020-01-22 11:56:27 +00:00
describe("<SendMessageComposer/>", () => {
2025-11-05 07:24:26 +00:00
const defaultRoomContext: RoomContextType = {
2026-07-01 13:49:18 +01:00
roomViewStore: SDKContextClass.instance.roomViewStore,
roomLoading: true,
peekLoading: false,
shouldPeek: true,
membersLoaded: false,
numUnreadMessages: 0,
canPeek: false,
showApps: false,
isPeeking: false,
showRightPanel: true,
joining: false,
atEndOfLiveTimeline: true,
showTopUnreadMessagesBar: false,
statusBarVisible: false,
canReact: false,
canSendMessages: false,
layout: Layout.Group,
lowBandwidth: false,
alwaysShowTimestamps: false,
showTwelveHourTimestamps: false,
2024-09-02 11:07:07 +02:00
userTimezone: undefined,
readMarkerInViewThresholdMs: 3000,
readMarkerOutOfViewThresholdMs: 30000,
showHiddenEvents: false,
showReadReceipts: true,
showRedactions: true,
showJoinLeaves: true,
showAvatarChanges: true,
showDisplaynameChanges: true,
showUrlPreview: false,
matrixClientIsReady: false,
timelineRenderingType: TimelineRenderingType.Room,
mainSplitContentType: MainSplitContentType.Timeline,
liveTimeline: undefined,
canSelfRedact: false,
resizing: false,
narrow: false,
msc3946ProcessDynamicPredecessor: false,
2023-08-07 08:27:09 +02:00
canAskToJoin: false,
promptAskToJoin: false,
viewRoomOpts: { buttons: [] },
isRoomEncrypted: false,
};
2020-01-22 11:56:27 +00:00
describe("createMessageContent", () => {
it("sends plaintext messages correctly", () => {
const model = new EditorModel([], createPartCreator());
const documentOffset = new DocumentOffset(11, true);
model.update("hello world", "insertText", documentOffset);
2020-01-22 11:56:27 +00:00
const content = createMessageContent("@alice:test", model, undefined, undefined);
2020-01-22 11:56:27 +00:00
expect(content).toEqual({
2023-09-14 13:36:15 +02:00
"body": "hello world",
"msgtype": "m.text",
"m.mentions": {},
2020-01-22 11:56:27 +00:00
});
});
it("sends markdown messages correctly", () => {
const model = new EditorModel([], createPartCreator());
const documentOffset = new DocumentOffset(13, true);
model.update("hello *world*", "insertText", documentOffset);
2020-01-22 11:56:27 +00:00
const content = createMessageContent("@alice:test", model, undefined, undefined);
2020-01-22 11:56:27 +00:00
expect(content).toEqual({
2023-09-14 13:36:15 +02:00
"body": "hello *world*",
"msgtype": "m.text",
"format": "org.matrix.custom.html",
"formatted_body": "hello <em>world</em>",
"m.mentions": {},
2020-01-22 11:56:27 +00:00
});
});
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);
2020-01-22 11:56:27 +00:00
const content = createMessageContent("@alice:test", model, undefined, undefined);
2020-01-22 11:56:27 +00:00
expect(content).toEqual({
2023-09-14 13:36:15 +02:00
"body": "blinks __quickly__",
"msgtype": "m.emote",
"format": "org.matrix.custom.html",
"formatted_body": "blinks <strong>quickly</strong>",
"m.mentions": {},
2020-01-22 11:56:27 +00:00
});
});
2022-03-21 15:09:43 -04:00
it("allows emoting with non-text parts", () => {
const model = new EditorModel([], createPartCreator());
2022-03-21 15:09:43 -04:00
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 = createMessageContent("@alice:test", model, undefined, undefined);
2022-03-21 15:09:43 -04:00
expect(content).toEqual({
2023-09-14 13:36:15 +02:00
"body": "✨sparkles✨",
"msgtype": "m.emote",
"m.mentions": {},
2022-03-21 15:09:43 -04:00
});
});
2020-01-22 11:56:27 +00:00
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);
2020-01-22 11:56:27 +00:00
const content = createMessageContent("@alice:test", model, undefined, undefined);
2020-01-22 11:56:27 +00:00
expect(content).toEqual({
2023-09-14 13:36:15 +02:00
"body": "/dev/null is my favourite place",
"msgtype": "m.text",
"m.mentions": {},
2020-01-22 11:56:27 +00:00
});
});
});
describe("functions correctly mounted", () => {
const mockClient = createTestClient();
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
const mockRoom = mkStubRoom("myfakeroom", "myfakeroom", mockClient) as any;
const mockEvent = mkEvent({
type: "m.room.message",
room: "myfakeroom",
user: "myfakeuser",
content: { msgtype: "m.text", body: "Replying to this" },
event: true,
});
mockRoom.findEventById = jest.fn((eventId) => {
return eventId === mockEvent.getId() ? mockEvent : null;
});
const spyDispatcher = jest.spyOn(defaultDispatcher, "dispatch");
beforeEach(() => {
localStorage.clear();
spyDispatcher.mockReset();
});
const urlPreviewVm = new MessageComposerUrlPreviewViewModel({
client: mockClient,
visible: false,
showTooltips: false,
urlPreviewBundle: false,
});
const defaultProps = {
room: mockRoom,
toggleStickerPickerOpen: jest.fn(),
};
const getRawComponent = (props = {}, roomContext = defaultRoomContext, client = mockClient) => (
<MatrixClientContext.Provider value={client}>
<ScopedRoomContextProvider room={mockRoom} {...roomContext}>
<RoomUploadContextProvider>
<SendMessageComposer {...defaultProps} {...props} urlPreviewVm={urlPreviewVm} />
</RoomUploadContextProvider>
</ScopedRoomContextProvider>
</MatrixClientContext.Provider>
);
const getComponent = (props = {}, roomContext = defaultRoomContext, client = mockClient) => {
return render(getRawComponent(props, roomContext, client), {
wrapper: ({ children }) => (
<SDKContext.Provider value={SDKContextClass.instance}>{children}</SDKContext.Provider>
),
});
};
2022-03-21 15:09:43 -04:00
it("renders text and placeholder correctly", () => {
const { container } = getComponent({ placeholder: "placeholder string" });
expect(container.querySelectorAll('[aria-label="placeholder string"]')).toHaveLength(1);
addTextToComposer(container, "Test Text");
expect(container.textContent).toBe("Test Text");
});
it("correctly persists state to and from localStorage", () => {
const props = { replyToEvent: mockEvent };
2024-11-20 13:29:23 +00:00
let { container, unmount } = getComponent(props);
addTextToComposer(container, "Test Text");
const key = "mx_cider_state_myfakeroom";
expect(container.textContent).toBe("Test Text");
expect(localStorage.getItem(key)).toBeNull();
// ensure the right state was persisted to localStorage
unmount();
expect(JSON.parse(localStorage.getItem(key)!)).toStrictEqual({
2021-06-29 13:11:58 +01:00
parts: [{ type: "plain", text: "Test Text" }],
replyEventId: mockEvent.getId(),
});
// ensure the correct model is re-loaded
2024-11-20 13:29:23 +00:00
({ container, unmount } = getComponent(props));
expect(container.textContent).toBe("Test Text");
expect(spyDispatcher).toHaveBeenCalledWith({
action: "reply_to_event",
event: mockEvent,
context: TimelineRenderingType.Room,
});
// now try with localStorage wiped out
unmount();
localStorage.removeItem(key);
2024-11-20 13:29:23 +00:00
({ container } = getComponent(props));
expect(container.textContent).toBe("");
});
it("persists state correctly without replyToEvent onbeforeunload", () => {
const { container } = getComponent();
addTextToComposer(container, "Hello World");
const key = "mx_cider_state_myfakeroom";
expect(container.textContent).toBe("Hello World");
expect(localStorage.getItem(key)).toBeNull();
// ensure the right state was persisted to localStorage
window.dispatchEvent(new Event("beforeunload"));
expect(JSON.parse(localStorage.getItem(key)!)).toStrictEqual({
2021-06-29 13:11:58 +01:00
parts: [{ type: "plain", text: "Hello World" }],
});
});
it("persists to session history upon sending", async () => {
mockPlatformPeg({ overrideBrowserShortcuts: jest.fn().mockReturnValue(false) });
2022-02-23 10:12:04 +01:00
const { container } = getComponent({ replyToEvent: mockEvent });
addTextToComposer(container, "This is a message");
fireEvent.keyDown(container.querySelector(".mx_SendMessageComposer")!, { key: "Enter" });
await waitFor(() => {
expect(spyDispatcher).toHaveBeenCalledWith({
action: "reply_to_event",
event: null,
context: TimelineRenderingType.Room,
});
});
expect(container.textContent).toBe("");
const str = sessionStorage.getItem(`mx_cider_history_${mockRoom.roomId}[0]`)!;
expect(JSON.parse(str)).toStrictEqual({
2021-06-29 13:11:58 +01:00
parts: [{ type: "plain", text: "This is a message" }],
replyEventId: mockEvent.getId(),
});
});
2022-07-13 07:56:36 +02:00
it("correctly sends a message", () => {
2022-11-04 10:48:08 +00:00
mocked(doMaybeLocalRoomAction).mockImplementation(
<T,>(roomId: string, fn: (actualRoomId: string) => Promise<T>, _client?: MatrixClient) => {
2022-07-13 07:56:36 +02:00
return fn(roomId);
},
);
mockPlatformPeg({ overrideBrowserShortcuts: jest.fn().mockReturnValue(false) });
const { container } = getComponent();
2022-07-13 07:56:36 +02:00
addTextToComposer(container, "test message");
fireEvent.keyDown(container.querySelector(".mx_SendMessageComposer")!, { key: "Enter" });
2022-07-13 07:56:36 +02:00
expect(mockClient.sendMessage).toHaveBeenCalledWith("myfakeroom", null, {
2023-09-14 13:36:15 +02:00
"body": "test message",
"msgtype": MsgType.Text,
"m.mentions": {},
2022-07-13 07:56:36 +02:00
});
});
it("correctly sends a reply using a slash command", async () => {
stubClient();
mocked(doMaybeLocalRoomAction).mockImplementation(
<T,>(roomId: string, fn: (actualRoomId: string) => Promise<T>, _client?: MatrixClient) => {
return fn(roomId);
},
);
const replyToEvent = mkEvent({
type: "m.room.message",
user: "@bob:test",
room: "!abc:test",
content: { "m.mentions": {} },
event: true,
});
mockPlatformPeg({ overrideBrowserShortcuts: jest.fn().mockReturnValue(false) });
const { container } = getComponent({ replyToEvent });
addTextToComposer(container, "/tableflip");
fireEvent.keyDown(container.querySelector(".mx_SendMessageComposer")!, { key: "Enter" });
await waitFor(() =>
expect(mockClient.sendMessage).toHaveBeenCalledWith("myfakeroom", null, {
"body": "(╯°□°)╯︵ ┻━┻",
"msgtype": MsgType.Text,
"m.mentions": {
user_ids: ["@bob:test"],
},
"m.relates_to": {
"m.in_reply_to": {
event_id: replyToEvent.getId(),
},
},
}),
);
});
it("shows chat effects on message sending", () => {
mocked(doMaybeLocalRoomAction).mockImplementation(
<T,>(roomId: string, fn: (actualRoomId: string) => Promise<T>, _client?: MatrixClient) => {
return fn(roomId);
},
);
mockPlatformPeg({ overrideBrowserShortcuts: jest.fn().mockReturnValue(false) });
const { container } = getComponent();
addTextToComposer(container, "🎉");
fireEvent.keyDown(container.querySelector(".mx_SendMessageComposer")!, { key: "Enter" });
expect(mockClient.sendMessage).toHaveBeenCalledWith("myfakeroom", null, {
2023-09-14 13:36:15 +02:00
"body": "test message",
"msgtype": MsgType.Text,
"m.mentions": {},
});
expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ action: `effects.confetti` });
});
it("not to send chat effects on message sending for threads", () => {
mocked(doMaybeLocalRoomAction).mockImplementation(
<T,>(roomId: string, fn: (actualRoomId: string) => Promise<T>, _client?: MatrixClient) => {
return fn(roomId);
},
);
mockPlatformPeg({ overrideBrowserShortcuts: jest.fn().mockReturnValue(false) });
const { container } = getComponent({
relation: {
rel_type: "m.thread",
event_id: "$yolo",
is_falling_back: true,
},
});
addTextToComposer(container, "🎉");
fireEvent.keyDown(container.querySelector(".mx_SendMessageComposer")!, { key: "Enter" });
expect(mockClient.sendMessage).toHaveBeenCalledWith("myfakeroom", null, {
2023-09-14 13:36:15 +02:00
"body": "test message",
"msgtype": MsgType.Text,
"m.mentions": {},
});
expect(defaultDispatcher.dispatch).not.toHaveBeenCalledWith({ action: `effects.confetti` });
});
});
2020-11-17 17:36:58 -05:00
describe("isQuickReaction", () => {
it("correctly detects quick reaction", () => {
const model = new EditorModel([], createPartCreator());
model.update("+😊", "insertText", new DocumentOffset(3, true));
2020-11-17 17:36:58 -05:00
const isReaction = isQuickReaction(model);
expect(isReaction).toBeTruthy();
});
it("correctly detects quick reaction with space", () => {
const model = new EditorModel([], createPartCreator());
model.update("+ 😊", "insertText", new DocumentOffset(4, true));
2020-11-17 17:36:58 -05:00
const isReaction = isQuickReaction(model);
expect(isReaction).toBeTruthy();
});
it("correctly rejects quick reaction with extra text", () => {
const model = new EditorModel([], createPartCreator());
const model2 = new EditorModel([], createPartCreator());
const model3 = new EditorModel([], createPartCreator());
const model4 = new EditorModel([], createPartCreator());
2022-01-10 12:57:20 +00:00
model.update("+😊hello", "insertText", new DocumentOffset(8, true));
model2.update(" +😊", "insertText", new DocumentOffset(4, true));
model3.update("+ 😊😊", "insertText", new DocumentOffset(6, true));
model4.update("+smiley", "insertText", new DocumentOffset(7, true));
2020-11-17 17:36:58 -05:00
expect(isQuickReaction(model)).toBeFalsy();
expect(isQuickReaction(model2)).toBeFalsy();
expect(isQuickReaction(model3)).toBeFalsy();
expect(isQuickReaction(model4)).toBeFalsy();
});
});
it("should call prepareToEncrypt when the user is typing", async () => {
const cli = stubClient();
const urlPreviewVm = new MessageComposerUrlPreviewViewModel({
client: cli,
visible: false,
showTooltips: false,
urlPreviewBundle: false,
});
cli.isRoomEncrypted = jest.fn().mockReturnValue(true);
const room = mkStubRoom("!roomId:server", "Room", cli);
expect(cli.getCrypto()!.prepareToEncrypt).not.toHaveBeenCalled();
const { container } = render(
<MatrixClientContext.Provider value={cli}>
<ScopedRoomContextProvider {...({ room } as unknown as RoomContextType)}>
<RoomUploadContextProvider>
<SendMessageComposer
room={room}
toggleStickerPickerOpen={jest.fn()}
urlPreviewVm={urlPreviewVm}
/>
</RoomUploadContextProvider>
</ScopedRoomContextProvider>
</MatrixClientContext.Provider>,
{
wrapper: ({ children }) => (
<SDKContext.Provider value={SDKContextClass.instance}>{children}</SDKContext.Provider>
),
},
);
const composer = container.querySelector<HTMLDivElement>(".mx_BasicMessageComposer_input")!;
// Does not trigger on keydown as that'll cause false negatives for global shortcuts
await userEvent.type(composer, "[ControlLeft>][KeyK][/ControlLeft]");
expect(cli.getCrypto()!.prepareToEncrypt).not.toHaveBeenCalled();
await userEvent.type(composer, "Hello");
expect(cli.getCrypto()!.prepareToEncrypt).toHaveBeenCalled();
});
2020-01-22 11:56:27 +00:00
});