Merge matrix-react-sdk into element-web
Merge remote-tracking branch 'repomerge/t3chguy/repomerge' into t3chguy/repo-merge Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
commit
f0ee7f7905
3265 changed files with 484599 additions and 699 deletions
76
test/unit-tests/components/views/rooms/AppsDrawer-test.tsx
Normal file
76
test/unit-tests/components/views/rooms/AppsDrawer-test.tsx
Normal file
|
@ -0,0 +1,76 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2024 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 { MatrixClient, PendingEventOrdering, Room } from "matrix-js-sdk/src/matrix";
|
||||
import { render } from "jest-matrix-react";
|
||||
|
||||
import { stubClient } from "../../../../test-utils";
|
||||
import AppsDrawer from "../../../../../src/components/views/rooms/AppsDrawer";
|
||||
import SdkConfig from "../../../../../src/SdkConfig";
|
||||
import ResizeNotifier from "../../../../../src/utils/ResizeNotifier";
|
||||
import { WidgetLayoutStore } from "../../../../../src/stores/widgets/WidgetLayoutStore";
|
||||
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
|
||||
|
||||
const ROOM_ID = "!room:id";
|
||||
|
||||
describe("AppsDrawer", () => {
|
||||
let client: MatrixClient;
|
||||
let room: Room;
|
||||
let dummyResizeNotifier: ResizeNotifier;
|
||||
|
||||
beforeEach(async () => {
|
||||
client = stubClient();
|
||||
room = new Room(ROOM_ID, client, client.getUserId()!, {
|
||||
pendingEventOrdering: PendingEventOrdering.Detached,
|
||||
});
|
||||
dummyResizeNotifier = new ResizeNotifier();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("honours default_widget_container_height", () => {
|
||||
jest.spyOn(SdkConfig, "get").mockImplementation((key) => {
|
||||
if (!key) {
|
||||
return {
|
||||
default_widget_container_height: 500,
|
||||
};
|
||||
}
|
||||
});
|
||||
jest.spyOn(WidgetLayoutStore.instance, "getContainerWidgets").mockImplementation((room, container) => {
|
||||
if (container === "top") {
|
||||
return [
|
||||
{
|
||||
id: "testwidget",
|
||||
creatorUserId: client.getUserId()!,
|
||||
type: "test",
|
||||
url: "https://nowhere.dummy/notawidget",
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<AppsDrawer
|
||||
userId={client.getUserId()!}
|
||||
room={room}
|
||||
resizeNotifier={dummyResizeNotifier}
|
||||
showApps={true}
|
||||
/>,
|
||||
{
|
||||
wrapper: ({ ...rest }) => <MatrixClientContext.Provider value={client} {...rest} />,
|
||||
},
|
||||
);
|
||||
|
||||
const appsDrawerResizer = container.getElementsByClassName("mx_AppsDrawer_resizer")[0] as HTMLElement;
|
||||
expect(appsDrawerResizer.style.height).toBe("500px");
|
||||
});
|
||||
});
|
|
@ -0,0 +1,125 @@
|
|||
/*
|
||||
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 React from "react";
|
||||
import { render, screen } from "jest-matrix-react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MatrixClient, Room } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import BasicMessageComposer from "../../../../../src/components/views/rooms/BasicMessageComposer";
|
||||
import * as TestUtils from "../../../../test-utils";
|
||||
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
|
||||
import EditorModel from "../../../../../src/editor/model";
|
||||
import { createPartCreator, createRenderer } from "../../../editor/mock";
|
||||
import SettingsStore from "../../../../../src/settings/SettingsStore";
|
||||
|
||||
describe("BasicMessageComposer", () => {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
|
||||
TestUtils.stubClient();
|
||||
|
||||
const client: MatrixClient = MatrixClientPeg.safeGet();
|
||||
|
||||
const roomId = "!1234567890:domain";
|
||||
const userId = client.getSafeUserId();
|
||||
const room = new Room(roomId, client, userId);
|
||||
|
||||
it("should allow a user to paste a URL without it being mangled", async () => {
|
||||
const model = new EditorModel([], pc, renderer);
|
||||
render(<BasicMessageComposer model={model} room={room} />);
|
||||
const testUrl = "https://element.io";
|
||||
const mockDataTransfer = generateMockDataTransferForString(testUrl);
|
||||
await userEvent.paste(mockDataTransfer);
|
||||
|
||||
expect(model.parts).toHaveLength(1);
|
||||
expect(model.parts[0].text).toBe(testUrl);
|
||||
expect(screen.getByText(testUrl)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should replaceEmoticons properly", async () => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName: string) => {
|
||||
return settingName === "MessageComposerInput.autoReplaceEmoji";
|
||||
});
|
||||
userEvent.setup();
|
||||
const model = new EditorModel([], pc, renderer);
|
||||
render(<BasicMessageComposer model={model} room={room} />);
|
||||
|
||||
const tranformations = [
|
||||
{ before: "4:3 video", after: "4:3 video" },
|
||||
{ before: "regexp 12345678", after: "regexp 12345678" },
|
||||
{ before: "--:--)", after: "--:--)" },
|
||||
|
||||
{ before: "we <3 matrix", after: "we ❤️ matrix" },
|
||||
{ before: "hello world :-)", after: "hello world 🙂" },
|
||||
{ before: ":) hello world", after: "🙂 hello world" },
|
||||
{ before: ":D 4:3 video :)", after: "😄 4:3 video 🙂" },
|
||||
|
||||
{ before: ":-D", after: "😄" },
|
||||
{ before: ":D", after: "😄" },
|
||||
{ before: ":3", after: "😽" },
|
||||
{ before: "=-]", after: "🙂" },
|
||||
];
|
||||
const input = screen.getByRole("textbox");
|
||||
|
||||
for (const { before, after } of tranformations) {
|
||||
await userEvent.clear(input);
|
||||
//add a space after the text to trigger the replacement
|
||||
await userEvent.type(input, before + " ");
|
||||
const transformedText = model.parts.map((part) => part.text).join("");
|
||||
expect(transformedText).toBe(after + " ");
|
||||
}
|
||||
});
|
||||
|
||||
it("should not mangle shift-enter when the autocomplete is open", async () => {
|
||||
const model = new EditorModel([], pc, renderer);
|
||||
render(<BasicMessageComposer model={model} room={room} />);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
|
||||
await userEvent.type(input, "/plain foobar");
|
||||
await userEvent.type(input, "{Shift>}{Enter}{/Shift}");
|
||||
const transformedText = model.parts.map((part) => part.text).join("");
|
||||
expect(transformedText).toBe("/plain foobar\n");
|
||||
});
|
||||
|
||||
it("should escape single quote in placeholder", async () => {
|
||||
const model = new EditorModel([], pc, renderer);
|
||||
const composer = render(<BasicMessageComposer placeholder="Don't" model={model} room={room} />);
|
||||
const input = composer.queryAllByRole("textbox");
|
||||
const placeholder = input[0].style.getPropertyValue("--placeholder");
|
||||
expect(placeholder).toMatch("'Don\\'t'");
|
||||
});
|
||||
|
||||
it("should escape backslash in placeholder", async () => {
|
||||
const model = new EditorModel([], pc, renderer);
|
||||
const composer = render(<BasicMessageComposer placeholder={"w\\e"} model={model} room={room} />);
|
||||
const input = composer.queryAllByRole("textbox");
|
||||
const placeholder = input[0].style.getPropertyValue("--placeholder");
|
||||
expect(placeholder).toMatch("'w\\\\e'");
|
||||
});
|
||||
});
|
||||
|
||||
function generateMockDataTransferForString(string: string): DataTransfer {
|
||||
return {
|
||||
getData: (type) => {
|
||||
if (type === "text/plain") {
|
||||
return string;
|
||||
}
|
||||
return "";
|
||||
},
|
||||
dropEffect: "link",
|
||||
effectAllowed: "link",
|
||||
files: {} as FileList,
|
||||
items: {} as DataTransferItemList,
|
||||
types: [],
|
||||
clearData: () => {},
|
||||
setData: () => {},
|
||||
setDragImage: () => {},
|
||||
};
|
||||
}
|
|
@ -0,0 +1,551 @@
|
|||
/*
|
||||
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("<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] as RoomMessageEventContent &
|
||||
ReplacementEvent<RoomMessageEventContent>;
|
||||
|
||||
// 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<RoomMessageEventContent>;
|
||||
|
||||
// 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<RoomMessageEventContent>;
|
||||
|
||||
// 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<RoomMessageEventContent>;
|
||||
|
||||
// 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<RoomMessageEventContent>;
|
||||
|
||||
// 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] as RoomMessageEventContent &
|
||||
ReplacementEvent<RoomMessageEventContent>;
|
||||
|
||||
// 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<RoomMessageEventContent>;
|
||||
|
||||
// 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<RoomMessageEventContent>;
|
||||
|
||||
// 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] as RoomMessageEventContent &
|
||||
ReplacementEvent<RoomMessageEventContent>;
|
||||
|
||||
// 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()],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
549
test/unit-tests/components/views/rooms/EventTile-test.tsx
Normal file
549
test/unit-tests/components/views/rooms/EventTile-test.tsx
Normal file
|
@ -0,0 +1,549 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022, 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 * as React from "react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "jest-matrix-react";
|
||||
import { mocked } from "jest-mock";
|
||||
import {
|
||||
EventType,
|
||||
IEventDecryptionResult,
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
NotificationCountType,
|
||||
PendingEventOrdering,
|
||||
Room,
|
||||
TweakName,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { CryptoApi, EventEncryptionInfo, EventShieldColour, EventShieldReason } from "matrix-js-sdk/src/crypto-api";
|
||||
import { mkEncryptedMatrixEvent } from "matrix-js-sdk/src/testing";
|
||||
|
||||
import EventTile, { EventTileProps } from "../../../../../src/components/views/rooms/EventTile";
|
||||
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
|
||||
import RoomContext, { TimelineRenderingType } from "../../../../../src/contexts/RoomContext";
|
||||
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
|
||||
import { filterConsole, flushPromises, getRoomContext, mkEvent, mkMessage, stubClient } from "../../../../test-utils";
|
||||
import { mkThread } from "../../../../test-utils/threads";
|
||||
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
|
||||
import dis from "../../../../../src/dispatcher/dispatcher";
|
||||
import { Action } from "../../../../../src/dispatcher/actions";
|
||||
import { IRoomState } from "../../../../../src/components/structures/RoomView";
|
||||
import PinningUtils from "../../../../../src/utils/PinningUtils";
|
||||
import { Layout } from "../../../../../src/settings/enums/Layout";
|
||||
|
||||
describe("EventTile", () => {
|
||||
const ROOM_ID = "!roomId:example.org";
|
||||
let mxEvent: MatrixEvent;
|
||||
let room: Room;
|
||||
let client: MatrixClient;
|
||||
|
||||
// let changeEvent: (event: MatrixEvent) => void;
|
||||
|
||||
/** wrap the EventTile up in context providers, and with basic properties, as it would be by MessagePanel normally. */
|
||||
function WrappedEventTile(props: {
|
||||
roomContext: IRoomState;
|
||||
eventTilePropertyOverrides?: Partial<EventTileProps>;
|
||||
}) {
|
||||
return (
|
||||
<MatrixClientContext.Provider value={client}>
|
||||
<RoomContext.Provider value={props.roomContext}>
|
||||
<EventTile
|
||||
mxEvent={mxEvent}
|
||||
replacingEventId={mxEvent.replacingEventId()}
|
||||
{...(props.eventTilePropertyOverrides ?? {})}
|
||||
/>
|
||||
</RoomContext.Provider>
|
||||
</MatrixClientContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function getComponent(
|
||||
overrides: Partial<EventTileProps> = {},
|
||||
renderingType: TimelineRenderingType = TimelineRenderingType.Room,
|
||||
) {
|
||||
const context = getRoomContext(room, {
|
||||
timelineRenderingType: renderingType,
|
||||
});
|
||||
return render(<WrappedEventTile roomContext={context} eventTilePropertyOverrides={overrides} />);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
stubClient();
|
||||
client = MatrixClientPeg.safeGet();
|
||||
|
||||
room = new Room(ROOM_ID, client, client.getSafeUserId(), {
|
||||
pendingEventOrdering: PendingEventOrdering.Detached,
|
||||
timelineSupport: true,
|
||||
});
|
||||
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room);
|
||||
jest.spyOn(client, "decryptEventIfNeeded").mockResolvedValue();
|
||||
|
||||
mxEvent = mkMessage({
|
||||
room: room.roomId,
|
||||
user: "@alice:example.org",
|
||||
msg: "Hello world!",
|
||||
event: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.spyOn(PinningUtils, "isPinned").mockReturnValue(false);
|
||||
});
|
||||
|
||||
describe("EventTile thread summary", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(client, "supportsThreads").mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("removes the thread summary when thread is deleted", async () => {
|
||||
const {
|
||||
rootEvent,
|
||||
events: [, reply],
|
||||
} = mkThread({
|
||||
room,
|
||||
client,
|
||||
authorId: "@alice:example.org",
|
||||
participantUserIds: ["@alice:example.org"],
|
||||
length: 2, // root + 1 answer
|
||||
});
|
||||
getComponent(
|
||||
{
|
||||
mxEvent: rootEvent,
|
||||
},
|
||||
TimelineRenderingType.Room,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId("thread-summary")).not.toBeNull());
|
||||
|
||||
const redaction = mkEvent({
|
||||
event: true,
|
||||
type: EventType.RoomRedaction,
|
||||
user: "@alice:example.org",
|
||||
room: room.roomId,
|
||||
redacts: reply.getId(),
|
||||
content: {},
|
||||
});
|
||||
|
||||
act(() => room.processThreadedEvents([redaction], false));
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId("thread-summary")).toBeNull());
|
||||
});
|
||||
});
|
||||
|
||||
describe("EventTile renderingType: ThreadsList", () => {
|
||||
it("shows an unread notification badge", () => {
|
||||
const { container } = getComponent({}, TimelineRenderingType.ThreadsList);
|
||||
|
||||
// By default, the thread will assume it is read.
|
||||
expect(container.getElementsByClassName("mx_NotificationBadge")).toHaveLength(0);
|
||||
|
||||
act(() => {
|
||||
room.setThreadUnreadNotificationCount(mxEvent.getId()!, NotificationCountType.Total, 3);
|
||||
});
|
||||
|
||||
expect(container.getElementsByClassName("mx_NotificationBadge")).toHaveLength(1);
|
||||
expect(container.getElementsByClassName("mx_NotificationBadge_level_highlight")).toHaveLength(0);
|
||||
|
||||
act(() => {
|
||||
room.setThreadUnreadNotificationCount(mxEvent.getId()!, NotificationCountType.Highlight, 1);
|
||||
});
|
||||
|
||||
expect(container.getElementsByClassName("mx_NotificationBadge")).toHaveLength(1);
|
||||
expect(container.getElementsByClassName("mx_NotificationBadge_level_highlight")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("EventTile renderingType: Threads", () => {
|
||||
it("should display the pinned message badge", async () => {
|
||||
jest.spyOn(PinningUtils, "isPinned").mockReturnValue(true);
|
||||
getComponent({}, TimelineRenderingType.Thread);
|
||||
|
||||
expect(screen.getByText("Pinned message")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("EventTile renderingType: File", () => {
|
||||
it("should not display the pinned message badge", async () => {
|
||||
jest.spyOn(PinningUtils, "isPinned").mockReturnValue(true);
|
||||
getComponent({}, TimelineRenderingType.File);
|
||||
|
||||
expect(screen.queryByText("Pinned message")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("EventTile renderingType: default", () => {
|
||||
it.each([[Layout.Group], [Layout.Bubble], [Layout.IRC]])(
|
||||
"should display the pinned message badge",
|
||||
async (layout) => {
|
||||
jest.spyOn(PinningUtils, "isPinned").mockReturnValue(true);
|
||||
getComponent({ layout });
|
||||
|
||||
expect(screen.getByText("Pinned message")).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("EventTile in the right panel", () => {
|
||||
beforeAll(() => {
|
||||
const dmRoomMap: DMRoomMap = {
|
||||
getUserIdForRoomId: jest.fn(),
|
||||
} as unknown as DMRoomMap;
|
||||
DMRoomMap.setShared(dmRoomMap);
|
||||
});
|
||||
|
||||
it("renders the room name for notifications", () => {
|
||||
const { container } = getComponent({}, TimelineRenderingType.Notification);
|
||||
expect(container.getElementsByClassName("mx_EventTile_details")[0]).toHaveTextContent(
|
||||
"@alice:example.org in !roomId:example.org",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the sender for the thread list", () => {
|
||||
const { container } = getComponent({}, TimelineRenderingType.ThreadsList);
|
||||
expect(container.getElementsByClassName("mx_EventTile_details")[0]).toHaveTextContent("@alice:example.org");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[TimelineRenderingType.Notification, Action.ViewRoom],
|
||||
[TimelineRenderingType.ThreadsList, Action.ShowThread],
|
||||
])("type %s dispatches %s", (renderingType, action) => {
|
||||
jest.spyOn(dis, "dispatch");
|
||||
|
||||
const { container } = getComponent({}, renderingType);
|
||||
|
||||
fireEvent.click(container.querySelector("li")!);
|
||||
|
||||
expect(dis.dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
describe("Event verification", () => {
|
||||
// data for our stubbed getEncryptionInfoForEvent: a map from event id to result
|
||||
const eventToEncryptionInfoMap = new Map<string, EventEncryptionInfo>();
|
||||
|
||||
beforeEach(() => {
|
||||
eventToEncryptionInfoMap.clear();
|
||||
|
||||
const mockCrypto = {
|
||||
// a mocked version of getEncryptionInfoForEvent which will pick its result from `eventToEncryptionInfoMap`
|
||||
getEncryptionInfoForEvent: async (event: MatrixEvent) => eventToEncryptionInfoMap.get(event.getId()!)!,
|
||||
} as unknown as CryptoApi;
|
||||
client.getCrypto = () => mockCrypto;
|
||||
});
|
||||
|
||||
it("shows a warning for an event from an unverified device", async () => {
|
||||
mxEvent = await mkEncryptedMatrixEvent({
|
||||
plainContent: { msgtype: "m.text", body: "msg1" },
|
||||
plainType: "m.room.message",
|
||||
sender: "@alice:example.org",
|
||||
roomId: room.roomId,
|
||||
});
|
||||
eventToEncryptionInfoMap.set(mxEvent.getId()!, {
|
||||
shieldColour: EventShieldColour.RED,
|
||||
shieldReason: EventShieldReason.UNSIGNED_DEVICE,
|
||||
} as EventEncryptionInfo);
|
||||
|
||||
const { container } = getComponent();
|
||||
await act(flushPromises);
|
||||
|
||||
const eventTiles = container.getElementsByClassName("mx_EventTile");
|
||||
expect(eventTiles).toHaveLength(1);
|
||||
|
||||
// there should be a warning shield
|
||||
expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(1);
|
||||
expect(container.getElementsByClassName("mx_EventTile_e2eIcon")[0].classList).toContain(
|
||||
"mx_EventTile_e2eIcon_warning",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows no shield for a verified event", async () => {
|
||||
mxEvent = await mkEncryptedMatrixEvent({
|
||||
plainContent: { msgtype: "m.text", body: "msg1" },
|
||||
plainType: "m.room.message",
|
||||
sender: "@alice:example.org",
|
||||
roomId: room.roomId,
|
||||
});
|
||||
eventToEncryptionInfoMap.set(mxEvent.getId()!, {
|
||||
shieldColour: EventShieldColour.NONE,
|
||||
shieldReason: null,
|
||||
} as EventEncryptionInfo);
|
||||
|
||||
const { container } = getComponent();
|
||||
await act(flushPromises);
|
||||
|
||||
const eventTiles = container.getElementsByClassName("mx_EventTile");
|
||||
expect(eventTiles).toHaveLength(1);
|
||||
|
||||
// there should be no warning
|
||||
expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[EventShieldReason.UNKNOWN, "Unknown error"],
|
||||
[EventShieldReason.UNVERIFIED_IDENTITY, "unverified user"],
|
||||
[EventShieldReason.UNSIGNED_DEVICE, "device not verified by its owner"],
|
||||
[EventShieldReason.UNKNOWN_DEVICE, "unknown or deleted device"],
|
||||
[EventShieldReason.AUTHENTICITY_NOT_GUARANTEED, "can't be guaranteed"],
|
||||
[EventShieldReason.MISMATCHED_SENDER_KEY, "Encrypted by an unverified session"],
|
||||
])("shows the correct reason code for %i (%s)", async (reasonCode: EventShieldReason, expectedText: string) => {
|
||||
mxEvent = await mkEncryptedMatrixEvent({
|
||||
plainContent: { msgtype: "m.text", body: "msg1" },
|
||||
plainType: "m.room.message",
|
||||
sender: "@alice:example.org",
|
||||
roomId: room.roomId,
|
||||
});
|
||||
eventToEncryptionInfoMap.set(mxEvent.getId()!, {
|
||||
shieldColour: EventShieldColour.GREY,
|
||||
shieldReason: reasonCode,
|
||||
} as EventEncryptionInfo);
|
||||
|
||||
const { container } = getComponent();
|
||||
await act(flushPromises);
|
||||
|
||||
const e2eIcons = container.getElementsByClassName("mx_EventTile_e2eIcon");
|
||||
expect(e2eIcons).toHaveLength(1);
|
||||
expect(e2eIcons[0].classList).toContain("mx_EventTile_e2eIcon_normal");
|
||||
fireEvent.focus(e2eIcons[0]);
|
||||
expect(e2eIcons[0].getAttribute("aria-labelledby")).toBeTruthy();
|
||||
expect(document.getElementById(e2eIcons[0].getAttribute("aria-labelledby")!)).toHaveTextContent(
|
||||
expectedText,
|
||||
);
|
||||
});
|
||||
|
||||
describe("undecryptable event", () => {
|
||||
filterConsole("Error decrypting event");
|
||||
|
||||
it("shows an undecryptable warning", async () => {
|
||||
mxEvent = mkEvent({
|
||||
type: "m.room.encrypted",
|
||||
room: room.roomId,
|
||||
user: "@alice:example.org",
|
||||
event: true,
|
||||
content: {},
|
||||
});
|
||||
|
||||
const mockCrypto = {
|
||||
decryptEvent: async (_ev): Promise<IEventDecryptionResult> => {
|
||||
throw new Error("can't decrypt");
|
||||
},
|
||||
} as Parameters<MatrixEvent["attemptDecryption"]>[0];
|
||||
await mxEvent.attemptDecryption(mockCrypto);
|
||||
|
||||
const { container } = getComponent();
|
||||
await act(flushPromises);
|
||||
|
||||
const eventTiles = container.getElementsByClassName("mx_EventTile");
|
||||
expect(eventTiles).toHaveLength(1);
|
||||
|
||||
expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(1);
|
||||
expect(container.getElementsByClassName("mx_EventTile_e2eIcon")[0].classList).toContain(
|
||||
"mx_EventTile_e2eIcon_decryption_failure",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should update the warning when the event is edited", async () => {
|
||||
// we start out with an event from the trusted device
|
||||
mxEvent = await mkEncryptedMatrixEvent({
|
||||
plainContent: { msgtype: "m.text", body: "msg1" },
|
||||
plainType: "m.room.message",
|
||||
sender: "@alice:example.org",
|
||||
roomId: room.roomId,
|
||||
});
|
||||
eventToEncryptionInfoMap.set(mxEvent.getId()!, {
|
||||
shieldColour: EventShieldColour.NONE,
|
||||
shieldReason: null,
|
||||
} as EventEncryptionInfo);
|
||||
|
||||
const roomContext = getRoomContext(room, {});
|
||||
const { container, rerender } = render(<WrappedEventTile roomContext={roomContext} />);
|
||||
|
||||
await act(flushPromises);
|
||||
|
||||
const eventTiles = container.getElementsByClassName("mx_EventTile");
|
||||
expect(eventTiles).toHaveLength(1);
|
||||
|
||||
// there should be no warning
|
||||
expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(0);
|
||||
|
||||
// then we replace the event with one from the unverified device
|
||||
const replacementEvent = await mkEncryptedMatrixEvent({
|
||||
plainContent: { msgtype: "m.text", body: "msg1" },
|
||||
plainType: "m.room.message",
|
||||
sender: "@alice:example.org",
|
||||
roomId: room.roomId,
|
||||
});
|
||||
eventToEncryptionInfoMap.set(replacementEvent.getId()!, {
|
||||
shieldColour: EventShieldColour.RED,
|
||||
shieldReason: EventShieldReason.UNSIGNED_DEVICE,
|
||||
} as EventEncryptionInfo);
|
||||
|
||||
await act(async () => {
|
||||
mxEvent.makeReplaced(replacementEvent);
|
||||
rerender(<WrappedEventTile roomContext={roomContext} />);
|
||||
await flushPromises;
|
||||
});
|
||||
|
||||
// check it was updated
|
||||
expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(1);
|
||||
expect(container.getElementsByClassName("mx_EventTile_e2eIcon")[0].classList).toContain(
|
||||
"mx_EventTile_e2eIcon_warning",
|
||||
);
|
||||
});
|
||||
|
||||
it("should update the warning when the event is replaced with an unencrypted one", async () => {
|
||||
jest.spyOn(client, "isRoomEncrypted").mockReturnValue(true);
|
||||
|
||||
// we start out with an event from the trusted device
|
||||
mxEvent = await mkEncryptedMatrixEvent({
|
||||
plainContent: { msgtype: "m.text", body: "msg1" },
|
||||
plainType: "m.room.message",
|
||||
sender: "@alice:example.org",
|
||||
roomId: room.roomId,
|
||||
});
|
||||
|
||||
eventToEncryptionInfoMap.set(mxEvent.getId()!, {
|
||||
shieldColour: EventShieldColour.NONE,
|
||||
shieldReason: null,
|
||||
} as EventEncryptionInfo);
|
||||
|
||||
const roomContext = getRoomContext(room, {});
|
||||
const { container, rerender } = render(<WrappedEventTile roomContext={roomContext} />);
|
||||
await act(flushPromises);
|
||||
|
||||
const eventTiles = container.getElementsByClassName("mx_EventTile");
|
||||
expect(eventTiles).toHaveLength(1);
|
||||
|
||||
// there should be no warning
|
||||
expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(0);
|
||||
|
||||
// then we replace the event with an unencrypted one
|
||||
const replacementEvent = await mkMessage({
|
||||
msg: "msg2",
|
||||
user: "@alice:example.org",
|
||||
room: room.roomId,
|
||||
event: true,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
mxEvent.makeReplaced(replacementEvent);
|
||||
rerender(<WrappedEventTile roomContext={roomContext} />);
|
||||
await flushPromises;
|
||||
});
|
||||
|
||||
// check it was updated
|
||||
expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(1);
|
||||
expect(container.getElementsByClassName("mx_EventTile_e2eIcon")[0].classList).toContain(
|
||||
"mx_EventTile_e2eIcon_warning",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("event highlighting", () => {
|
||||
const isHighlighted = (container: HTMLElement): boolean =>
|
||||
!!container.getElementsByClassName("mx_EventTile_highlight").length;
|
||||
|
||||
beforeEach(() => {
|
||||
mocked(client.getPushActionsForEvent).mockReturnValue(null);
|
||||
});
|
||||
|
||||
it("does not highlight message where message matches no push actions", () => {
|
||||
const { container } = getComponent();
|
||||
|
||||
expect(client.getPushActionsForEvent).toHaveBeenCalledWith(mxEvent);
|
||||
expect(isHighlighted(container)).toBeFalsy();
|
||||
});
|
||||
|
||||
it(`does not highlight when message's push actions does not have a highlight tweak`, () => {
|
||||
mocked(client.getPushActionsForEvent).mockReturnValue({ notify: true, tweaks: {} });
|
||||
const { container } = getComponent();
|
||||
|
||||
expect(isHighlighted(container)).toBeFalsy();
|
||||
});
|
||||
|
||||
it(`highlights when message's push actions have a highlight tweak`, () => {
|
||||
mocked(client.getPushActionsForEvent).mockReturnValue({
|
||||
notify: true,
|
||||
tweaks: { [TweakName.Highlight]: true },
|
||||
});
|
||||
const { container } = getComponent();
|
||||
|
||||
expect(isHighlighted(container)).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("when a message has been edited", () => {
|
||||
let editingEvent: MatrixEvent;
|
||||
|
||||
beforeEach(() => {
|
||||
editingEvent = new MatrixEvent({
|
||||
type: "m.room.message",
|
||||
room_id: ROOM_ID,
|
||||
sender: "@alice:example.org",
|
||||
content: {
|
||||
"msgtype": "m.text",
|
||||
"body": "* edited body",
|
||||
"m.new_content": {
|
||||
msgtype: "m.text",
|
||||
body: "edited body",
|
||||
},
|
||||
"m.relates_to": {
|
||||
rel_type: "m.replace",
|
||||
event_id: mxEvent.getId(),
|
||||
},
|
||||
},
|
||||
});
|
||||
mxEvent.makeReplaced(editingEvent);
|
||||
});
|
||||
|
||||
it("does not highlight message where no version of message matches any push actions", () => {
|
||||
const { container } = getComponent();
|
||||
|
||||
// get push actions for both events
|
||||
expect(client.getPushActionsForEvent).toHaveBeenCalledWith(mxEvent);
|
||||
expect(client.getPushActionsForEvent).toHaveBeenCalledWith(editingEvent);
|
||||
expect(isHighlighted(container)).toBeFalsy();
|
||||
});
|
||||
|
||||
it(`does not highlight when no version of message's push actions have a highlight tweak`, () => {
|
||||
mocked(client.getPushActionsForEvent).mockReturnValue({ notify: true, tweaks: {} });
|
||||
const { container } = getComponent();
|
||||
|
||||
expect(isHighlighted(container)).toBeFalsy();
|
||||
});
|
||||
|
||||
it(`highlights when previous version of message's push actions have a highlight tweak`, () => {
|
||||
mocked(client.getPushActionsForEvent).mockImplementation((event: MatrixEvent) => {
|
||||
if (event === mxEvent) {
|
||||
return { notify: true, tweaks: { [TweakName.Highlight]: true } };
|
||||
}
|
||||
return { notify: false, tweaks: {} };
|
||||
});
|
||||
const { container } = getComponent();
|
||||
|
||||
expect(isHighlighted(container)).toBeTruthy();
|
||||
});
|
||||
|
||||
it(`highlights when new version of message's push actions have a highlight tweak`, () => {
|
||||
mocked(client.getPushActionsForEvent).mockImplementation((event: MatrixEvent) => {
|
||||
if (event === editingEvent) {
|
||||
return { notify: true, tweaks: { [TweakName.Highlight]: true } };
|
||||
}
|
||||
return { notify: false, tweaks: {} };
|
||||
});
|
||||
const { container } = getComponent();
|
||||
|
||||
expect(isHighlighted(container)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
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 { getByLabelText, render, RenderResult } from "jest-matrix-react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import React, { ComponentProps } from "react";
|
||||
|
||||
import { EventTileThreadToolbar } from "../../../../../../src/components/views/rooms/EventTile/EventTileThreadToolbar";
|
||||
|
||||
describe("EventTileThreadToolbar", () => {
|
||||
const viewInRoom = jest.fn();
|
||||
const copyLink = jest.fn();
|
||||
|
||||
function renderComponent(props: Partial<ComponentProps<typeof EventTileThreadToolbar>> = {}): RenderResult {
|
||||
return render(<EventTileThreadToolbar viewInRoom={viewInRoom} copyLinkToThread={copyLink} {...props} />);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it("renders", () => {
|
||||
const { asFragment } = renderComponent();
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("calls the right callbacks", async () => {
|
||||
const { container } = renderComponent();
|
||||
|
||||
const copyBtn = getByLabelText(container, "Copy link to thread");
|
||||
const viewInRoomBtn = getByLabelText(container, "View in room");
|
||||
|
||||
await userEvent.click(copyBtn);
|
||||
expect(copyLink).toHaveBeenCalledTimes(1);
|
||||
|
||||
await userEvent.click(viewInRoomBtn);
|
||||
expect(viewInRoom).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,29 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`EventTileThreadToolbar renders 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
aria-label="Message Actions"
|
||||
aria-live="off"
|
||||
class="mx_MessageActionBar"
|
||||
role="toolbar"
|
||||
>
|
||||
<div
|
||||
aria-label="View in room"
|
||||
class="mx_AccessibleButton mx_MessageActionBar_iconButton"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div />
|
||||
</div>
|
||||
<div
|
||||
aria-label="Copy link to thread"
|
||||
class="mx_AccessibleButton mx_MessageActionBar_iconButton"
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div />
|
||||
</div>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
53
test/unit-tests/components/views/rooms/ExtraTile-test.tsx
Normal file
53
test/unit-tests/components/views/rooms/ExtraTile-test.tsx
Normal file
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
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 { getByRole, render } from "jest-matrix-react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import React, { ComponentProps } from "react";
|
||||
|
||||
import ExtraTile from "../../../../../src/components/views/rooms/ExtraTile";
|
||||
|
||||
describe("ExtraTile", () => {
|
||||
function renderComponent(props: Partial<ComponentProps<typeof ExtraTile>> = {}) {
|
||||
const defaultProps: ComponentProps<typeof ExtraTile> = {
|
||||
isMinimized: false,
|
||||
isSelected: false,
|
||||
displayName: "test",
|
||||
avatar: <React.Fragment />,
|
||||
onClick: () => {},
|
||||
};
|
||||
return render(<ExtraTile {...defaultProps} {...props} />);
|
||||
}
|
||||
|
||||
it("renders", () => {
|
||||
const { asFragment } = renderComponent();
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("hides text when minimized", () => {
|
||||
const { container } = renderComponent({
|
||||
isMinimized: true,
|
||||
displayName: "testDisplayName",
|
||||
});
|
||||
expect(container).not.toHaveTextContent("testDisplayName");
|
||||
});
|
||||
|
||||
it("registers clicks", async () => {
|
||||
const onClick = jest.fn();
|
||||
|
||||
const { container } = renderComponent({
|
||||
onClick,
|
||||
});
|
||||
|
||||
const btn = getByRole(container, "treeitem");
|
||||
|
||||
await userEvent.click(btn);
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
441
test/unit-tests/components/views/rooms/MemberList-test.tsx
Normal file
441
test/unit-tests/components/views/rooms/MemberList-test.tsx
Normal file
|
@ -0,0 +1,441 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
|
||||
|
||||
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 { act, fireEvent, render, RenderResult, screen, waitFor, waitForElementToBeRemoved } from "jest-matrix-react";
|
||||
import { Room, MatrixClient, RoomState, RoomMember, User, MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||
import { mocked, MockedObject } from "jest-mock";
|
||||
|
||||
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
|
||||
import * as TestUtils from "../../../../test-utils";
|
||||
import MemberList from "../../../../../src/components/views/rooms/MemberList";
|
||||
import { SDKContext } from "../../../../../src/contexts/SDKContext";
|
||||
import { TestSdkContext } from "../../../TestSdkContext";
|
||||
import {
|
||||
filterConsole,
|
||||
flushPromises,
|
||||
getMockClientWithEventEmitter,
|
||||
mockClientMethodsRooms,
|
||||
mockClientMethodsUser,
|
||||
} from "../../../../test-utils";
|
||||
import { shouldShowComponent } from "../../../../../src/customisations/helpers/UIComponents";
|
||||
import defaultDispatcher from "../../../../../src/dispatcher/dispatcher";
|
||||
|
||||
jest.mock("../../../../../src/customisations/helpers/UIComponents", () => ({
|
||||
shouldShowComponent: jest.fn(),
|
||||
}));
|
||||
|
||||
function generateRoomId() {
|
||||
return "!" + Math.random().toString().slice(2, 10) + ":domain";
|
||||
}
|
||||
|
||||
describe("MemberList", () => {
|
||||
filterConsole(
|
||||
"Age for event was not available, using `now - origin_server_ts` as a fallback. If the device clock is not correct issues might occur.",
|
||||
);
|
||||
function createRoom(opts = {}) {
|
||||
const room = new Room(generateRoomId(), client, client.getUserId()!);
|
||||
if (opts) {
|
||||
Object.assign(room, opts);
|
||||
}
|
||||
return room;
|
||||
}
|
||||
|
||||
let client: MatrixClient;
|
||||
let root: RenderResult;
|
||||
let memberListRoom: Room;
|
||||
let memberList: MemberList;
|
||||
|
||||
let adminUsers: RoomMember[] = [];
|
||||
let moderatorUsers: RoomMember[] = [];
|
||||
let defaultUsers: RoomMember[] = [];
|
||||
|
||||
function memberString(member: RoomMember): string {
|
||||
if (!member) {
|
||||
return "(null)";
|
||||
} else {
|
||||
const u = member.user;
|
||||
return (
|
||||
"(" +
|
||||
member.name +
|
||||
", " +
|
||||
member.powerLevel +
|
||||
", " +
|
||||
(u ? u.lastActiveAgo : "<null>") +
|
||||
", " +
|
||||
(u ? u.getLastActiveTs() : "<null>") +
|
||||
", " +
|
||||
(u ? u.currentlyActive : "<null>") +
|
||||
", " +
|
||||
(u ? u.presence : "<null>") +
|
||||
")"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function expectOrderedByPresenceAndPowerLevel(memberTiles: NodeListOf<Element>, isPresenceEnabled: boolean) {
|
||||
let prevMember: RoomMember | undefined;
|
||||
for (const tile of memberTiles) {
|
||||
const memberA = prevMember;
|
||||
const memberB = memberListRoom.currentState.members[tile.getAttribute("aria-label")!.split(" ")[0]];
|
||||
prevMember = memberB; // just in case an expect fails, set this early
|
||||
if (!memberA) {
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log("COMPARING A VS B:", memberString(memberA), memberString(memberB));
|
||||
|
||||
const userA = memberA.user!;
|
||||
const userB = memberB.user!;
|
||||
|
||||
let groupChange = false;
|
||||
|
||||
if (isPresenceEnabled) {
|
||||
const convertPresence = (p: string) => (p === "unavailable" ? "online" : p);
|
||||
const presenceIndex = (p: string) => {
|
||||
const order = ["active", "online", "offline"];
|
||||
const idx = order.indexOf(convertPresence(p));
|
||||
return idx === -1 ? order.length : idx; // unknown states at the end
|
||||
};
|
||||
|
||||
const idxA = presenceIndex(userA.currentlyActive ? "active" : userA.presence);
|
||||
const idxB = presenceIndex(userB.currentlyActive ? "active" : userB.presence);
|
||||
console.log("Comparing presence groups...");
|
||||
expect(idxB).toBeGreaterThanOrEqual(idxA);
|
||||
groupChange = idxA !== idxB;
|
||||
} else {
|
||||
console.log("Skipped presence groups");
|
||||
}
|
||||
|
||||
if (!groupChange) {
|
||||
console.log("Comparing power levels...");
|
||||
expect(memberA.powerLevel).toBeGreaterThanOrEqual(memberB.powerLevel);
|
||||
groupChange = memberA.powerLevel !== memberB.powerLevel;
|
||||
} else {
|
||||
console.log("Skipping power level check due to group change");
|
||||
}
|
||||
|
||||
if (!groupChange) {
|
||||
if (isPresenceEnabled) {
|
||||
console.log("Comparing last active timestamp...");
|
||||
expect(userB.getLastActiveTs()).toBeLessThanOrEqual(userA.getLastActiveTs());
|
||||
groupChange = userA.getLastActiveTs() !== userB.getLastActiveTs();
|
||||
} else {
|
||||
console.log("Skipping last active timestamp");
|
||||
}
|
||||
} else {
|
||||
console.log("Skipping last active timestamp check due to group change");
|
||||
}
|
||||
|
||||
if (!groupChange) {
|
||||
const nameA = memberA.name[0] === "@" ? memberA.name.slice(1) : memberA.name;
|
||||
const nameB = memberB.name[0] === "@" ? memberB.name.slice(1) : memberB.name;
|
||||
const collator = new Intl.Collator();
|
||||
const nameCompare = collator.compare(nameB, nameA);
|
||||
console.log("Comparing name");
|
||||
expect(nameCompare).toBeGreaterThanOrEqual(0);
|
||||
} else {
|
||||
console.log("Skipping name check due to group change");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderMemberList(enablePresence: boolean): void {
|
||||
TestUtils.stubClient();
|
||||
client = MatrixClientPeg.safeGet();
|
||||
client.hasLazyLoadMembersEnabled = () => false;
|
||||
|
||||
// Make room
|
||||
memberListRoom = createRoom();
|
||||
expect(memberListRoom.roomId).toBeTruthy();
|
||||
|
||||
// Make users
|
||||
adminUsers = [];
|
||||
moderatorUsers = [];
|
||||
defaultUsers = [];
|
||||
const usersPerLevel = 2;
|
||||
for (let i = 0; i < usersPerLevel; i++) {
|
||||
const adminUser = new RoomMember(memberListRoom.roomId, `@admin${i}:localhost`);
|
||||
adminUser.membership = KnownMembership.Join;
|
||||
adminUser.powerLevel = 100;
|
||||
adminUser.user = User.createUser(adminUser.userId, client);
|
||||
adminUser.user.currentlyActive = true;
|
||||
adminUser.user.presence = "online";
|
||||
adminUser.user.lastPresenceTs = 1000;
|
||||
adminUser.user.lastActiveAgo = 10;
|
||||
adminUsers.push(adminUser);
|
||||
|
||||
const moderatorUser = new RoomMember(memberListRoom.roomId, `@moderator${i}:localhost`);
|
||||
moderatorUser.membership = KnownMembership.Join;
|
||||
moderatorUser.powerLevel = 50;
|
||||
moderatorUser.user = User.createUser(moderatorUser.userId, client);
|
||||
moderatorUser.user.currentlyActive = true;
|
||||
moderatorUser.user.presence = "online";
|
||||
moderatorUser.user.lastPresenceTs = 1000;
|
||||
moderatorUser.user.lastActiveAgo = 10;
|
||||
moderatorUsers.push(moderatorUser);
|
||||
|
||||
const defaultUser = new RoomMember(memberListRoom.roomId, `@default${i}:localhost`);
|
||||
defaultUser.membership = KnownMembership.Join;
|
||||
defaultUser.powerLevel = 0;
|
||||
defaultUser.user = User.createUser(defaultUser.userId, client);
|
||||
defaultUser.user.currentlyActive = true;
|
||||
defaultUser.user.presence = "online";
|
||||
defaultUser.user.lastPresenceTs = 1000;
|
||||
defaultUser.user.lastActiveAgo = 10;
|
||||
defaultUsers.push(defaultUser);
|
||||
}
|
||||
|
||||
client.getRoom = (roomId) => {
|
||||
if (roomId === memberListRoom.roomId) return memberListRoom;
|
||||
else return null;
|
||||
};
|
||||
memberListRoom.currentState = {
|
||||
members: {},
|
||||
getMember: jest.fn(),
|
||||
getStateEvents: ((eventType, stateKey) =>
|
||||
stateKey === undefined ? [] : null) as RoomState["getStateEvents"], // ignore 3pid invites
|
||||
} as unknown as RoomState;
|
||||
for (const member of [...adminUsers, ...moderatorUsers, ...defaultUsers]) {
|
||||
memberListRoom.currentState.members[member.userId] = member;
|
||||
}
|
||||
|
||||
const gatherWrappedRef = (r: MemberList) => {
|
||||
memberList = r;
|
||||
};
|
||||
const context = new TestSdkContext();
|
||||
context.client = client;
|
||||
context.memberListStore.isPresenceEnabled = jest.fn().mockReturnValue(enablePresence);
|
||||
root = render(
|
||||
<SDKContext.Provider value={context}>
|
||||
<MemberList
|
||||
searchQuery=""
|
||||
onClose={jest.fn()}
|
||||
onSearchQueryChanged={jest.fn()}
|
||||
roomId={memberListRoom.roomId}
|
||||
ref={gatherWrappedRef}
|
||||
/>
|
||||
</SDKContext.Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe.each([false, true])("does order members correctly (presence %s)", (enablePresence) => {
|
||||
beforeEach(function () {
|
||||
renderMemberList(enablePresence);
|
||||
});
|
||||
|
||||
describe("does order members correctly", () => {
|
||||
// Note: even if presence is disabled, we still expect that the presence
|
||||
// tests will pass. All expectOrderedByPresenceAndPowerLevel does is ensure
|
||||
// the order is perceived correctly, regardless of what we did to the members.
|
||||
|
||||
// Each of the 4 tests here is done to prove that the member list can meet
|
||||
// all 4 criteria independently. Together, they should work.
|
||||
|
||||
it("by presence state", async () => {
|
||||
// Intentionally pick users that will confuse the power level sorting
|
||||
const activeUsers = [defaultUsers[0]];
|
||||
const onlineUsers = [adminUsers[0]];
|
||||
const offlineUsers = [...moderatorUsers, ...adminUsers.slice(1), ...defaultUsers.slice(1)];
|
||||
activeUsers.forEach((u) => {
|
||||
u.user!.currentlyActive = true;
|
||||
u.user!.presence = "online";
|
||||
});
|
||||
onlineUsers.forEach((u) => {
|
||||
u.user!.currentlyActive = false;
|
||||
u.user!.presence = "online";
|
||||
});
|
||||
offlineUsers.forEach((u) => {
|
||||
u.user!.currentlyActive = false;
|
||||
u.user!.presence = "offline";
|
||||
});
|
||||
|
||||
// Bypass all the event listeners and skip to the good part
|
||||
await act(() => memberList.updateListNow(true));
|
||||
|
||||
const tiles = root.container.querySelectorAll(".mx_EntityTile");
|
||||
expectOrderedByPresenceAndPowerLevel(tiles, enablePresence);
|
||||
});
|
||||
|
||||
it("by power level", async () => {
|
||||
// We already have admin, moderator, and default users so leave them alone
|
||||
|
||||
// Bypass all the event listeners and skip to the good part
|
||||
await act(() => memberList.updateListNow(true));
|
||||
|
||||
const tiles = root.container.querySelectorAll(".mx_EntityTile");
|
||||
expectOrderedByPresenceAndPowerLevel(tiles, enablePresence);
|
||||
});
|
||||
|
||||
it("by last active timestamp", async () => {
|
||||
// Intentionally pick users that will confuse the power level sorting
|
||||
// lastActiveAgoTs == lastPresenceTs - lastActiveAgo
|
||||
const activeUsers = [defaultUsers[0]];
|
||||
const semiActiveUsers = [adminUsers[0]];
|
||||
const inactiveUsers = [...moderatorUsers, ...adminUsers.slice(1), ...defaultUsers.slice(1)];
|
||||
activeUsers.forEach((u) => {
|
||||
u.powerLevel = 100; // set everyone to the same PL to avoid running that check
|
||||
u.user!.lastPresenceTs = 1000;
|
||||
u.user!.lastActiveAgo = 0;
|
||||
});
|
||||
semiActiveUsers.forEach((u) => {
|
||||
u.powerLevel = 100;
|
||||
u.user!.lastPresenceTs = 1000;
|
||||
u.user!.lastActiveAgo = 50;
|
||||
});
|
||||
inactiveUsers.forEach((u) => {
|
||||
u.powerLevel = 100;
|
||||
u.user!.lastPresenceTs = 1000;
|
||||
u.user!.lastActiveAgo = 100;
|
||||
});
|
||||
|
||||
// Bypass all the event listeners and skip to the good part
|
||||
await act(() => memberList.updateListNow(true));
|
||||
|
||||
const tiles = root.container.querySelectorAll(".mx_EntityTile");
|
||||
expectOrderedByPresenceAndPowerLevel(tiles, enablePresence);
|
||||
});
|
||||
|
||||
it("by name", async () => {
|
||||
// Intentionally put everyone on the same level to force a name comparison
|
||||
const allUsers = [...adminUsers, ...moderatorUsers, ...defaultUsers];
|
||||
allUsers.forEach((u) => {
|
||||
u.user!.currentlyActive = true;
|
||||
u.user!.presence = "online";
|
||||
u.user!.lastPresenceTs = 1000;
|
||||
u.user!.lastActiveAgo = 0;
|
||||
u.powerLevel = 100;
|
||||
});
|
||||
|
||||
// Bypass all the event listeners and skip to the good part
|
||||
await act(() => memberList.updateListNow(true));
|
||||
|
||||
const tiles = root.container.querySelectorAll(".mx_EntityTile");
|
||||
expectOrderedByPresenceAndPowerLevel(tiles, enablePresence);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("memberlist is rendered correctly", () => {
|
||||
beforeEach(function () {
|
||||
renderMemberList(true);
|
||||
});
|
||||
|
||||
it("memberlist is re-rendered on unreachable presence event", async () => {
|
||||
defaultUsers[0].user?.setPresenceEvent(
|
||||
new MatrixEvent({
|
||||
type: "m.presence",
|
||||
sender: defaultUsers[0].userId,
|
||||
content: {
|
||||
presence: "io.element.unreachable",
|
||||
currently_active: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(await screen.findByText(/User's server unreachable/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("Invite button", () => {
|
||||
const roomId = "!room:server.org";
|
||||
let client!: MockedObject<MatrixClient>;
|
||||
let room!: Room;
|
||||
|
||||
beforeEach(function () {
|
||||
mocked(shouldShowComponent).mockReturnValue(true);
|
||||
client = getMockClientWithEventEmitter({
|
||||
...mockClientMethodsUser(),
|
||||
...mockClientMethodsRooms(),
|
||||
getRoom: jest.fn(),
|
||||
hasLazyLoadMembersEnabled: jest.fn(),
|
||||
});
|
||||
room = new Room(roomId, client, client.getSafeUserId());
|
||||
client.getRoom.mockReturnValue(room);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
const renderComponent = () => {
|
||||
const context = new TestSdkContext();
|
||||
context.client = client;
|
||||
return render(
|
||||
<SDKContext.Provider value={context}>
|
||||
<MemberList
|
||||
searchQuery=""
|
||||
onClose={jest.fn()}
|
||||
onSearchQueryChanged={jest.fn()}
|
||||
roomId={room.roomId}
|
||||
/>
|
||||
</SDKContext.Provider>,
|
||||
);
|
||||
};
|
||||
|
||||
it("does not render invite button when current user is not a member", async () => {
|
||||
renderComponent();
|
||||
await flushPromises();
|
||||
|
||||
expect(screen.queryByText("Invite to this room")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render invite button UI customisation hides invites", async () => {
|
||||
mocked(shouldShowComponent).mockReturnValue(false);
|
||||
renderComponent();
|
||||
await flushPromises();
|
||||
|
||||
expect(screen.queryByText("Invite to this room")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders disabled invite button when current user is a member but does not have rights to invite", async () => {
|
||||
jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Join);
|
||||
jest.spyOn(room, "canInvite").mockReturnValue(false);
|
||||
|
||||
renderComponent();
|
||||
await flushPromises();
|
||||
|
||||
// button rendered but disabled
|
||||
expect(screen.getByText("Invite to this room")).toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
|
||||
it("renders enabled invite button when current user is a member and has rights to invite", async () => {
|
||||
jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Join);
|
||||
jest.spyOn(room, "canInvite").mockReturnValue(true);
|
||||
|
||||
renderComponent();
|
||||
await flushPromises();
|
||||
|
||||
expect(screen.getByText("Invite to this room")).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("opens room inviter on button click", async () => {
|
||||
jest.spyOn(defaultDispatcher, "dispatch");
|
||||
jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Join);
|
||||
jest.spyOn(room, "canInvite").mockReturnValue(true);
|
||||
|
||||
const { getByRole } = renderComponent();
|
||||
await waitForElementToBeRemoved(() => screen.queryAllByRole("progressbar"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getByRole("button", { name: "Invite to this room" })).not.toHaveAttribute(
|
||||
"aria-disabled",
|
||||
"true",
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.click(getByRole("button", { name: "Invite to this room" }));
|
||||
|
||||
expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({
|
||||
action: "view_invite",
|
||||
roomId,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
73
test/unit-tests/components/views/rooms/MemberTile-test.tsx
Normal file
73
test/unit-tests/components/views/rooms/MemberTile-test.tsx
Normal file
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
* 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 { render, screen, waitFor } from "jest-matrix-react";
|
||||
import { MatrixClient, RoomMember, Device } from "matrix-js-sdk/src/matrix";
|
||||
import { UserVerificationStatus, DeviceVerificationStatus } from "matrix-js-sdk/src/crypto-api";
|
||||
import { mocked } from "jest-mock";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import * as TestUtils from "../../../../test-utils";
|
||||
import MemberTile from "../../../../../src/components/views/rooms/MemberTile";
|
||||
|
||||
describe("MemberTile", () => {
|
||||
let matrixClient: MatrixClient;
|
||||
let member: RoomMember;
|
||||
|
||||
beforeEach(() => {
|
||||
matrixClient = TestUtils.stubClient();
|
||||
mocked(matrixClient.isRoomEncrypted).mockReturnValue(true);
|
||||
member = new RoomMember("roomId", matrixClient.getUserId()!);
|
||||
});
|
||||
|
||||
it("should not display an E2EIcon when the e2E status = normal", () => {
|
||||
const { container } = render(<MemberTile member={member} />);
|
||||
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should display an warning E2EIcon when the e2E status = Warning", async () => {
|
||||
mocked(matrixClient.getCrypto()!.getUserVerificationStatus).mockResolvedValue({
|
||||
isCrossSigningVerified: jest.fn().mockReturnValue(false),
|
||||
wasCrossSigningVerified: jest.fn().mockReturnValue(true),
|
||||
} as unknown as UserVerificationStatus);
|
||||
|
||||
const { container } = render(<MemberTile member={member} />);
|
||||
|
||||
expect(container).toMatchSnapshot();
|
||||
await waitFor(async () => {
|
||||
await userEvent.hover(container.querySelector(".mx_E2EIcon")!);
|
||||
expect(screen.getByText("This user has not verified all of their sessions.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display an verified E2EIcon when the e2E status = Verified", async () => {
|
||||
// Mock all the required crypto methods
|
||||
const deviceMap = new Map<string, Map<string, Device>>();
|
||||
deviceMap.set(member.userId, new Map([["deviceId", {} as Device]]));
|
||||
// Return a DeviceMap = Map<string, Map<string, Device>>
|
||||
mocked(matrixClient.getCrypto()!.getUserDeviceInfo).mockResolvedValue(deviceMap);
|
||||
mocked(matrixClient.getCrypto()!.getUserVerificationStatus).mockResolvedValue({
|
||||
isCrossSigningVerified: jest.fn().mockReturnValue(true),
|
||||
} as unknown as UserVerificationStatus);
|
||||
mocked(matrixClient.getCrypto()!.getDeviceVerificationStatus).mockResolvedValue({
|
||||
crossSigningVerified: true,
|
||||
} as DeviceVerificationStatus);
|
||||
|
||||
const { container } = render(<MemberTile member={member} />);
|
||||
|
||||
expect(container).toMatchSnapshot();
|
||||
await waitFor(async () => {
|
||||
await userEvent.hover(container.querySelector(".mx_E2EIcon")!);
|
||||
expect(
|
||||
screen.getByText("You have verified this user. This user has verified all of their sessions."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
535
test/unit-tests/components/views/rooms/MessageComposer-test.tsx
Normal file
535
test/unit-tests/components/views/rooms/MessageComposer-test.tsx
Normal file
|
@ -0,0 +1,535 @@
|
|||
/*
|
||||
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 * as React from "react";
|
||||
import { EventType, MatrixEvent, Room, RoomMember, THREAD_RELATION_TYPE } from "matrix-js-sdk/src/matrix";
|
||||
import { act, fireEvent, render, screen, waitFor } from "jest-matrix-react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import {
|
||||
clearAllModals,
|
||||
createTestClient,
|
||||
flushPromises,
|
||||
mkEvent,
|
||||
mkStubRoom,
|
||||
mockPlatformPeg,
|
||||
stubClient,
|
||||
waitEnoughCyclesForModal,
|
||||
} from "../../../../test-utils";
|
||||
import MessageComposer from "../../../../../src/components/views/rooms/MessageComposer";
|
||||
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
|
||||
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
|
||||
import RoomContext from "../../../../../src/contexts/RoomContext";
|
||||
import { IRoomState } from "../../../../../src/components/structures/RoomView";
|
||||
import ResizeNotifier from "../../../../../src/utils/ResizeNotifier";
|
||||
import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks";
|
||||
import { LocalRoom } from "../../../../../src/models/LocalRoom";
|
||||
import { Features } from "../../../../../src/settings/Settings";
|
||||
import SettingsStore from "../../../../../src/settings/SettingsStore";
|
||||
import { SettingLevel } from "../../../../../src/settings/SettingLevel";
|
||||
import dis from "../../../../../src/dispatcher/dispatcher";
|
||||
import { E2EStatus } from "../../../../../src/utils/ShieldUtils";
|
||||
import { addTextToComposerRTL } from "../../../../test-utils/composer";
|
||||
import UIStore, { UI_EVENTS } from "../../../../../src/stores/UIStore";
|
||||
import { Action } from "../../../../../src/dispatcher/actions";
|
||||
import { VoiceBroadcastInfoState, VoiceBroadcastRecording } from "../../../../../src/voice-broadcast";
|
||||
import { mkVoiceBroadcastInfoStateEvent } from "../../../voice-broadcast/utils/test-utils";
|
||||
import { SdkContextClass } from "../../../../../src/contexts/SDKContext";
|
||||
|
||||
const openStickerPicker = async (): Promise<void> => {
|
||||
await act(async () => {
|
||||
await userEvent.click(screen.getByLabelText("More options"));
|
||||
await userEvent.click(screen.getByLabelText("Sticker"));
|
||||
});
|
||||
};
|
||||
|
||||
const startVoiceMessage = async (): Promise<void> => {
|
||||
await act(async () => {
|
||||
await userEvent.click(screen.getByLabelText("More options"));
|
||||
await userEvent.click(screen.getByLabelText("Voice Message"));
|
||||
});
|
||||
};
|
||||
|
||||
const setCurrentBroadcastRecording = (room: Room, state: VoiceBroadcastInfoState): void => {
|
||||
const recording = new VoiceBroadcastRecording(
|
||||
mkVoiceBroadcastInfoStateEvent(room.roomId, state, "@user:example.com", "ABC123"),
|
||||
MatrixClientPeg.safeGet(),
|
||||
state,
|
||||
);
|
||||
SdkContextClass.instance.voiceBroadcastRecordingsStore.setCurrent(recording);
|
||||
};
|
||||
|
||||
const expectVoiceMessageRecordingTriggered = (): void => {
|
||||
// Checking for the voice message dialog text, if no mic can be found.
|
||||
// By this we know at least that starting a voice message was triggered.
|
||||
expect(screen.getByText("No microphone found")).toBeInTheDocument();
|
||||
};
|
||||
|
||||
describe("MessageComposer", () => {
|
||||
stubClient();
|
||||
const cli = createTestClient();
|
||||
|
||||
beforeEach(() => {
|
||||
mockPlatformPeg();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await clearAllModals();
|
||||
jest.useRealTimers();
|
||||
|
||||
SdkContextClass.instance.voiceBroadcastRecordingsStore.clearCurrent();
|
||||
|
||||
// restore settings
|
||||
act(() => {
|
||||
[
|
||||
"MessageComposerInput.showStickersButton",
|
||||
"MessageComposerInput.showPollsButton",
|
||||
Features.VoiceBroadcast,
|
||||
"feature_wysiwyg_composer",
|
||||
].forEach((setting: string): void => {
|
||||
SettingsStore.setValue(setting, null, SettingLevel.DEVICE, SettingsStore.getDefaultValue(setting));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("for a Room", () => {
|
||||
const room = mkStubRoom("!roomId:server", "Room 1", cli);
|
||||
|
||||
it("Renders a SendMessageComposer and MessageComposerButtons by default", () => {
|
||||
wrapAndRender({ room });
|
||||
expect(screen.getByLabelText("Send a message…")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Does not render a SendMessageComposer or MessageComposerButtons when user has no permission", () => {
|
||||
wrapAndRender({ room }, false);
|
||||
expect(screen.queryByLabelText("Send a message…")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("You do not have permission to post to this room")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Does not render a SendMessageComposer or MessageComposerButtons when room is tombstoned", () => {
|
||||
wrapAndRender(
|
||||
{ room },
|
||||
true,
|
||||
false,
|
||||
mkEvent({
|
||||
event: true,
|
||||
type: "m.room.tombstone",
|
||||
room: room.roomId,
|
||||
user: "@user1:server",
|
||||
skey: "",
|
||||
content: {},
|
||||
ts: Date.now(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(screen.queryByLabelText("Send a message…")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("This room has been replaced and is no longer active.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("when receiving a »reply_to_event«", () => {
|
||||
let roomContext: IRoomState;
|
||||
let resizeNotifier: ResizeNotifier;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
resizeNotifier = {
|
||||
notifyTimelineHeightChanged: jest.fn(),
|
||||
} as unknown as ResizeNotifier;
|
||||
roomContext = wrapAndRender({
|
||||
room,
|
||||
resizeNotifier,
|
||||
}).roomContext;
|
||||
});
|
||||
|
||||
it("should call notifyTimelineHeightChanged() for the same context", () => {
|
||||
dis.dispatch({
|
||||
action: "reply_to_event",
|
||||
context: roomContext.timelineRenderingType,
|
||||
});
|
||||
|
||||
jest.advanceTimersByTime(150);
|
||||
expect(resizeNotifier.notifyTimelineHeightChanged).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not call notifyTimelineHeightChanged() for a different context", () => {
|
||||
dis.dispatch({
|
||||
action: "reply_to_event",
|
||||
context: "test",
|
||||
});
|
||||
|
||||
jest.advanceTimersByTime(150);
|
||||
expect(resizeNotifier.notifyTimelineHeightChanged).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// test button display depending on settings
|
||||
[
|
||||
{
|
||||
setting: "MessageComposerInput.showStickersButton",
|
||||
buttonLabel: "Sticker",
|
||||
},
|
||||
{
|
||||
setting: "MessageComposerInput.showPollsButton",
|
||||
buttonLabel: "Poll",
|
||||
},
|
||||
{
|
||||
setting: Features.VoiceBroadcast,
|
||||
buttonLabel: "Voice broadcast",
|
||||
},
|
||||
].forEach(({ setting, buttonLabel }) => {
|
||||
[true, false].forEach((value: boolean) => {
|
||||
describe(`when ${setting} = ${value}`, () => {
|
||||
beforeEach(async () => {
|
||||
SettingsStore.setValue(setting, null, SettingLevel.DEVICE, value);
|
||||
wrapAndRender({ room });
|
||||
await act(async () => {
|
||||
await userEvent.click(screen.getByLabelText("More options"));
|
||||
});
|
||||
});
|
||||
|
||||
it(`should${value || "not"} display the button`, () => {
|
||||
if (value) {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect(screen.getByLabelText(buttonLabel)).toBeInTheDocument();
|
||||
} else {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect(screen.queryByLabelText(buttonLabel)).not.toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
describe(`and setting ${setting} to ${!value}`, () => {
|
||||
beforeEach(async () => {
|
||||
// simulate settings update
|
||||
await SettingsStore.setValue(setting, null, SettingLevel.DEVICE, !value);
|
||||
dis.dispatch(
|
||||
{
|
||||
action: Action.SettingUpdated,
|
||||
settingName: setting,
|
||||
newValue: !value,
|
||||
},
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it(`should${!value || "not"} display the button`, () => {
|
||||
if (!value) {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect(screen.getByLabelText(buttonLabel)).toBeInTheDocument();
|
||||
} else {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect(screen.queryByLabelText(buttonLabel)).not.toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should not render the send button", () => {
|
||||
wrapAndRender({ room });
|
||||
expect(screen.queryByLabelText("Send message")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("when a message has been entered", () => {
|
||||
beforeEach(async () => {
|
||||
const renderResult = wrapAndRender({ room }).renderResult;
|
||||
await addTextToComposerRTL(renderResult, "Hello");
|
||||
});
|
||||
|
||||
it("should render the send button", () => {
|
||||
expect(screen.getByLabelText("Send message")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("UIStore interactions", () => {
|
||||
let resizeCallback: Function;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(UIStore.instance, "on").mockImplementation(
|
||||
(_event: string | symbol, listener: Function): any => {
|
||||
resizeCallback = listener;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("when a non-resize event occurred in UIStore", () => {
|
||||
beforeEach(async () => {
|
||||
wrapAndRender({ room });
|
||||
await openStickerPicker();
|
||||
resizeCallback("test", {});
|
||||
});
|
||||
|
||||
it("should still display the sticker picker", () => {
|
||||
expect(screen.getByText("You don't currently have any stickerpacks enabled")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when a resize to narrow event occurred in UIStore", () => {
|
||||
beforeEach(async () => {
|
||||
wrapAndRender({ room }, true, true);
|
||||
await openStickerPicker();
|
||||
resizeCallback(UI_EVENTS.Resize, {});
|
||||
});
|
||||
|
||||
it("should close the menu", () => {
|
||||
expect(screen.queryByLabelText("Sticker")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show the attachment button", () => {
|
||||
expect(screen.queryByLabelText("Attachment")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should close the sticker picker", () => {
|
||||
expect(
|
||||
screen.queryByText("You don't currently have any stickerpacks enabled"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when a resize to non-narrow event occurred in UIStore", () => {
|
||||
beforeEach(async () => {
|
||||
wrapAndRender({ room }, true, false);
|
||||
await openStickerPicker();
|
||||
resizeCallback(UI_EVENTS.Resize, {});
|
||||
});
|
||||
|
||||
it("should close the menu", () => {
|
||||
expect(screen.queryByLabelText("Sticker")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show the attachment button", () => {
|
||||
expect(screen.getByLabelText("Attachment")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should close the sticker picker", () => {
|
||||
expect(
|
||||
screen.queryByText("You don't currently have any stickerpacks enabled"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when not replying to an event", () => {
|
||||
it("should pass the expected placeholder to SendMessageComposer", () => {
|
||||
wrapAndRender({ room });
|
||||
expect(screen.getByLabelText("Send a message…")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("and an e2e status it should pass the expected placeholder to SendMessageComposer", () => {
|
||||
wrapAndRender({
|
||||
room,
|
||||
e2eStatus: E2EStatus.Normal,
|
||||
});
|
||||
expect(screen.getByLabelText("Send an encrypted message…")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when replying to an event", () => {
|
||||
let replyToEvent: MatrixEvent;
|
||||
let props: Partial<React.ComponentProps<typeof MessageComposer>>;
|
||||
|
||||
const checkPlaceholder = (expected: string) => {
|
||||
it("should pass the expected placeholder to SendMessageComposer", () => {
|
||||
wrapAndRender(props);
|
||||
expect(screen.getByLabelText(expected)).toBeInTheDocument();
|
||||
});
|
||||
};
|
||||
|
||||
const setEncrypted = () => {
|
||||
beforeEach(() => {
|
||||
props.e2eStatus = E2EStatus.Normal;
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
replyToEvent = mkEvent({
|
||||
event: true,
|
||||
type: EventType.RoomMessage,
|
||||
user: cli.getUserId()!,
|
||||
content: {},
|
||||
});
|
||||
|
||||
props = {
|
||||
room,
|
||||
replyToEvent,
|
||||
};
|
||||
});
|
||||
|
||||
describe("without encryption", () => {
|
||||
checkPlaceholder("Send a reply…");
|
||||
});
|
||||
|
||||
describe("with encryption", () => {
|
||||
setEncrypted();
|
||||
checkPlaceholder("Send an encrypted reply…");
|
||||
});
|
||||
|
||||
describe("with a non-thread relation", () => {
|
||||
beforeEach(() => {
|
||||
props.relation = { rel_type: "test" };
|
||||
});
|
||||
|
||||
checkPlaceholder("Send a reply…");
|
||||
});
|
||||
|
||||
describe("that is a thread", () => {
|
||||
beforeEach(() => {
|
||||
props.relation = { rel_type: THREAD_RELATION_TYPE.name };
|
||||
});
|
||||
|
||||
checkPlaceholder("Reply to thread…");
|
||||
|
||||
describe("with encryption", () => {
|
||||
setEncrypted();
|
||||
checkPlaceholder("Reply to encrypted thread…");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when clicking start a voice message", () => {
|
||||
beforeEach(async () => {
|
||||
wrapAndRender({ room });
|
||||
await startVoiceMessage();
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
it("should try to start a voice message", () => {
|
||||
expectVoiceMessageRecordingTriggered();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when recording a voice broadcast and trying to start a voice message", () => {
|
||||
beforeEach(async () => {
|
||||
setCurrentBroadcastRecording(room, VoiceBroadcastInfoState.Started);
|
||||
wrapAndRender({ room });
|
||||
await startVoiceMessage();
|
||||
await waitEnoughCyclesForModal();
|
||||
});
|
||||
|
||||
it("should not start a voice message and display the info dialog", async () => {
|
||||
expect(screen.queryByLabelText("Stop recording")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Can't start voice message")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when there is a stopped voice broadcast recording and trying to start a voice message", () => {
|
||||
beforeEach(async () => {
|
||||
setCurrentBroadcastRecording(room, VoiceBroadcastInfoState.Stopped);
|
||||
wrapAndRender({ room });
|
||||
await startVoiceMessage();
|
||||
await waitEnoughCyclesForModal();
|
||||
});
|
||||
|
||||
it("should try to start a voice message and should not display the info dialog", async () => {
|
||||
expect(screen.queryByText("Can't start voice message")).not.toBeInTheDocument();
|
||||
expectVoiceMessageRecordingTriggered();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("for a LocalRoom", () => {
|
||||
const localRoom = new LocalRoom("!room:example.com", cli, cli.getUserId()!);
|
||||
|
||||
it("should not show the stickers button", async () => {
|
||||
wrapAndRender({ room: localRoom });
|
||||
await act(async () => {
|
||||
await userEvent.click(screen.getByLabelText("More options"));
|
||||
});
|
||||
expect(screen.queryByLabelText("Sticker")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("wysiwyg correctly persists state to and from localStorage", async () => {
|
||||
const room = mkStubRoom("!roomId:server", "Room 1", cli);
|
||||
const messageText = "Test Text";
|
||||
await SettingsStore.setValue("feature_wysiwyg_composer", null, SettingLevel.DEVICE, true);
|
||||
const { renderResult, rawComponent } = wrapAndRender({ room });
|
||||
const { unmount, rerender } = renderResult;
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
const key = `mx_wysiwyg_state_${room.roomId}`;
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(screen.getByRole("textbox"));
|
||||
});
|
||||
fireEvent.input(screen.getByRole("textbox"), {
|
||||
data: messageText,
|
||||
inputType: "insertText",
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveTextContent(messageText));
|
||||
|
||||
// Wait for event dispatch to happen
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
// assert there is state persisted
|
||||
expect(localStorage.getItem(key)).toBeNull();
|
||||
|
||||
// ensure the right state was persisted to localStorage
|
||||
unmount();
|
||||
|
||||
// assert the persisted state
|
||||
expect(JSON.parse(localStorage.getItem(key)!)).toStrictEqual({
|
||||
content: messageText,
|
||||
isRichText: true,
|
||||
});
|
||||
|
||||
// ensure the correct state is re-loaded
|
||||
rerender(rawComponent);
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveTextContent(messageText));
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
function wrapAndRender(
|
||||
props: Partial<React.ComponentProps<typeof MessageComposer>> = {},
|
||||
canSendMessages = true,
|
||||
narrow = false,
|
||||
tombstone?: MatrixEvent,
|
||||
) {
|
||||
const mockClient = MatrixClientPeg.safeGet();
|
||||
const roomId = "myroomid";
|
||||
const room: any = props.room || {
|
||||
currentState: undefined,
|
||||
roomId,
|
||||
client: mockClient,
|
||||
getMember: function (userId: string): RoomMember {
|
||||
return new RoomMember(roomId, userId);
|
||||
},
|
||||
};
|
||||
|
||||
const roomContext = {
|
||||
room,
|
||||
canSendMessages,
|
||||
tombstone,
|
||||
narrow,
|
||||
} as unknown as IRoomState;
|
||||
|
||||
const defaultProps = {
|
||||
room,
|
||||
resizeNotifier: new ResizeNotifier(),
|
||||
permalinkCreator: new RoomPermalinkCreator(room),
|
||||
};
|
||||
|
||||
const getRawComponent = (props = {}, context = roomContext, client = mockClient) => (
|
||||
<MatrixClientContext.Provider value={client}>
|
||||
<RoomContext.Provider value={context}>
|
||||
<MessageComposer {...defaultProps} {...props} />
|
||||
</RoomContext.Provider>
|
||||
</MatrixClientContext.Provider>
|
||||
);
|
||||
return {
|
||||
rawComponent: getRawComponent(props, roomContext, mockClient),
|
||||
renderResult: render(getRawComponent(props, roomContext, mockClient)),
|
||||
roomContext,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,194 @@
|
|||
/*
|
||||
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 React from "react";
|
||||
import { render, screen, waitFor } from "jest-matrix-react";
|
||||
|
||||
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
|
||||
import RoomContext from "../../../../../src/contexts/RoomContext";
|
||||
import { createTestClient, getRoomContext, mkStubRoom } from "../../../../test-utils";
|
||||
import { IRoomState } from "../../../../../src/components/structures/RoomView";
|
||||
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
|
||||
import MessageComposerButtons from "../../../../../src/components/views/rooms/MessageComposerButtons";
|
||||
|
||||
describe("MessageComposerButtons", () => {
|
||||
// @ts-ignore - we're deliberately not implementing the whole interface here, but
|
||||
// can't use Partial<> for types because it'll annoy TS more than it helps.
|
||||
const mockProps: React.ComponentProps<typeof MessageComposerButtons> = {
|
||||
addEmoji: () => false,
|
||||
haveRecording: false,
|
||||
isStickerPickerOpen: false,
|
||||
menuPosition: undefined,
|
||||
onRecordStartEndClick: () => {},
|
||||
setStickerPickerOpen: () => {},
|
||||
toggleButtonMenu: () => {},
|
||||
};
|
||||
|
||||
const mockClient = createTestClient();
|
||||
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
|
||||
|
||||
function getButtonLabels() {
|
||||
const getLabels = (elements: HTMLElement[]): string[] =>
|
||||
elements
|
||||
.map((element) => element.getAttribute("aria-label"))
|
||||
.filter((label): label is string => label !== null);
|
||||
|
||||
const mainLabels: Array<string | string[]> = getLabels(screen.queryAllByRole("button"));
|
||||
const menuLabels = getLabels(screen.queryAllByRole("menuitem"));
|
||||
|
||||
if (menuLabels.length) {
|
||||
mainLabels.push(getLabels(screen.queryAllByRole("menuitem")));
|
||||
}
|
||||
|
||||
return mainLabels;
|
||||
}
|
||||
|
||||
function wrapAndRender(component: React.ReactElement, narrow: boolean) {
|
||||
const mockRoom = mkStubRoom("myfakeroom", "myfakeroom", mockClient) as any;
|
||||
const defaultRoomContext: IRoomState = getRoomContext(mockRoom, { narrow });
|
||||
|
||||
return render(
|
||||
<MatrixClientContext.Provider value={mockClient}>
|
||||
<RoomContext.Provider value={defaultRoomContext}>{component}</RoomContext.Provider>
|
||||
</MatrixClientContext.Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
it("Renders emoji and upload buttons in wide mode", () => {
|
||||
wrapAndRender(
|
||||
<MessageComposerButtons
|
||||
{...mockProps}
|
||||
isMenuOpen={false}
|
||||
showLocationButton={true}
|
||||
showPollsButton={true}
|
||||
showStickersButton={true}
|
||||
/>,
|
||||
false,
|
||||
);
|
||||
|
||||
expect(getButtonLabels()).toEqual(["Emoji", "Attachment", "More options"]);
|
||||
});
|
||||
|
||||
it("Renders other buttons in menu in wide mode", async () => {
|
||||
wrapAndRender(
|
||||
<MessageComposerButtons
|
||||
{...mockProps}
|
||||
isMenuOpen={true}
|
||||
showLocationButton={true}
|
||||
showPollsButton={true}
|
||||
showStickersButton={true}
|
||||
/>,
|
||||
false,
|
||||
);
|
||||
|
||||
// The location code is lazy loaded, so the button will take a little while
|
||||
// to appear, so we need to wait.
|
||||
await waitFor(() => {
|
||||
expect(getButtonLabels()).toEqual([
|
||||
"Emoji",
|
||||
"Attachment",
|
||||
"More options",
|
||||
["Sticker", "Voice Message", "Poll", "Location"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("Renders only some buttons in narrow mode", () => {
|
||||
wrapAndRender(
|
||||
<MessageComposerButtons
|
||||
{...mockProps}
|
||||
isMenuOpen={false}
|
||||
showLocationButton={true}
|
||||
showPollsButton={true}
|
||||
showStickersButton={true}
|
||||
/>,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(getButtonLabels()).toEqual(["Emoji", "More options"]);
|
||||
});
|
||||
|
||||
it("Renders other buttons in menu (except voice messages) in narrow mode", () => {
|
||||
wrapAndRender(
|
||||
<MessageComposerButtons
|
||||
{...mockProps}
|
||||
isMenuOpen={true}
|
||||
showLocationButton={true}
|
||||
showPollsButton={true}
|
||||
showStickersButton={true}
|
||||
/>,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(getButtonLabels()).toEqual(["Emoji", "More options", ["Attachment", "Sticker", "Poll", "Location"]]);
|
||||
});
|
||||
|
||||
describe("polls button", () => {
|
||||
it("should render when asked to", () => {
|
||||
wrapAndRender(
|
||||
<MessageComposerButtons
|
||||
{...mockProps}
|
||||
isMenuOpen={true}
|
||||
showLocationButton={true}
|
||||
showPollsButton={true}
|
||||
showStickersButton={true}
|
||||
/>,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(getButtonLabels()).toEqual(["Emoji", "More options", ["Attachment", "Sticker", "Poll", "Location"]]);
|
||||
});
|
||||
|
||||
it("should not render when asked not to", () => {
|
||||
wrapAndRender(
|
||||
<MessageComposerButtons
|
||||
{...mockProps}
|
||||
isMenuOpen={true}
|
||||
showLocationButton={true}
|
||||
showPollsButton={false} // !! the change from the alternate test
|
||||
showStickersButton={true}
|
||||
/>,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(getButtonLabels()).toEqual([
|
||||
"Emoji",
|
||||
"More options",
|
||||
[
|
||||
"Attachment",
|
||||
"Sticker",
|
||||
// "Poll", // should be hidden
|
||||
"Location",
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("with showVoiceBroadcastButton = true", () => {
|
||||
it("should render the »Voice broadcast« button", () => {
|
||||
wrapAndRender(
|
||||
<MessageComposerButtons
|
||||
{...mockProps}
|
||||
isMenuOpen={true}
|
||||
showLocationButton={true}
|
||||
showPollsButton={true}
|
||||
showStickersButton={true}
|
||||
showVoiceBroadcastButton={true}
|
||||
/>,
|
||||
false,
|
||||
);
|
||||
|
||||
expect(getButtonLabels()).toEqual([
|
||||
"Emoji",
|
||||
"Attachment",
|
||||
"More options",
|
||||
["Sticker", "Voice Message", "Voice broadcast", "Poll", "Location"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
91
test/unit-tests/components/views/rooms/NewRoomIntro-test.tsx
Normal file
91
test/unit-tests/components/views/rooms/NewRoomIntro-test.tsx
Normal file
|
@ -0,0 +1,91 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2015-2022 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
|
||||
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 { render, screen } from "jest-matrix-react";
|
||||
import { MatrixClient, Room } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { LocalRoom } from "../../../../../src/models/LocalRoom";
|
||||
import { filterConsole, mkRoomMemberJoinEvent, mkThirdPartyInviteEvent, stubClient } from "../../../../test-utils";
|
||||
import RoomContext from "../../../../../src/contexts/RoomContext";
|
||||
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
|
||||
import NewRoomIntro from "../../../../../src/components/views/rooms/NewRoomIntro";
|
||||
import { IRoomState } from "../../../../../src/components/structures/RoomView";
|
||||
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
|
||||
import { DirectoryMember } from "../../../../../src/utils/direct-messages";
|
||||
|
||||
const renderNewRoomIntro = (client: MatrixClient, room: Room | LocalRoom) => {
|
||||
render(
|
||||
<MatrixClientContext.Provider value={client}>
|
||||
<RoomContext.Provider value={{ room, roomId: room.roomId } as unknown as IRoomState}>
|
||||
<NewRoomIntro />
|
||||
</RoomContext.Provider>
|
||||
</MatrixClientContext.Provider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe("NewRoomIntro", () => {
|
||||
let client: MatrixClient;
|
||||
const roomId = "!room:example.com";
|
||||
const userId = "@user:example.com";
|
||||
|
||||
filterConsole("Room !room:example.com does not have an m.room.create event");
|
||||
|
||||
beforeAll(() => {
|
||||
client = stubClient();
|
||||
DMRoomMap.makeShared(client);
|
||||
});
|
||||
|
||||
describe("for a DM Room", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(userId);
|
||||
const room = new Room(roomId, client, client.getUserId()!);
|
||||
room.name = "test_room";
|
||||
renderNewRoomIntro(client, room);
|
||||
});
|
||||
|
||||
it("should render the expected intro", () => {
|
||||
const expected = `This is the beginning of your direct message history with test_room.`;
|
||||
screen.getByText((id, element) => element?.tagName === "SPAN" && element?.textContent === expected);
|
||||
});
|
||||
});
|
||||
|
||||
it("should render as expected for a DM room with a single third-party invite", () => {
|
||||
const room = new Room(roomId, client, client.getSafeUserId());
|
||||
room.currentState.setStateEvents([
|
||||
mkRoomMemberJoinEvent(client.getSafeUserId(), room.roomId),
|
||||
mkThirdPartyInviteEvent(client.getSafeUserId(), "user@example.com", room.roomId),
|
||||
]);
|
||||
jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(userId);
|
||||
jest.spyOn(DMRoomMap.shared(), "getRoomIds").mockReturnValue(new Set([room.roomId]));
|
||||
renderNewRoomIntro(client, room);
|
||||
|
||||
expect(screen.getByText("Once everyone has joined, you’ll be able to chat")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(
|
||||
"Only the two of you are in this conversation, unless either of you invites anyone to join.",
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("for a DM LocalRoom", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(userId);
|
||||
const localRoom = new LocalRoom(roomId, client, client.getUserId()!);
|
||||
localRoom.name = "test_room";
|
||||
localRoom.targets.push(new DirectoryMember({ user_id: userId }));
|
||||
renderNewRoomIntro(client, localRoom);
|
||||
});
|
||||
|
||||
it("should render the expected intro", () => {
|
||||
const expected = `Send your first message to invite test_room to chat`;
|
||||
screen.getByText((id, element) => element?.tagName === "SPAN" && element?.textContent === expected);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,74 @@
|
|||
/*
|
||||
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 { fireEvent, render } from "jest-matrix-react";
|
||||
import React from "react";
|
||||
|
||||
import { StatelessNotificationBadge } from "../../../../../../src/components/views/rooms/NotificationBadge/StatelessNotificationBadge";
|
||||
import SettingsStore from "../../../../../../src/settings/SettingsStore";
|
||||
import { NotificationLevel } from "../../../../../../src/stores/notifications/NotificationLevel";
|
||||
import NotificationBadge from "../../../../../../src/components/views/rooms/NotificationBadge";
|
||||
import { NotificationState } from "../../../../../../src/stores/notifications/NotificationState";
|
||||
|
||||
class DummyNotificationState extends NotificationState {
|
||||
constructor(level: NotificationLevel) {
|
||||
super();
|
||||
this._level = level;
|
||||
}
|
||||
}
|
||||
|
||||
describe("NotificationBadge", () => {
|
||||
it("shows a dot if the level is activity", () => {
|
||||
const notif = new DummyNotificationState(NotificationLevel.Activity);
|
||||
|
||||
const { container } = render(<NotificationBadge roomId="!foo:bar" notification={notif} />);
|
||||
expect(container.querySelector(".mx_NotificationBadge_dot")).toBeInTheDocument();
|
||||
expect(container.querySelector(".mx_NotificationBadge")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show a dot if the level is activity and hideIfDot is true", () => {
|
||||
const notif = new DummyNotificationState(NotificationLevel.Activity);
|
||||
|
||||
const { container } = render(<NotificationBadge roomId="!foo:bar" notification={notif} hideIfDot={true} />);
|
||||
expect(container.querySelector(".mx_NotificationBadge_dot")).not.toBeInTheDocument();
|
||||
expect(container.querySelector(".mx_NotificationBadge")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("still shows an empty badge if hideIfDot us true", () => {
|
||||
const notif = new DummyNotificationState(NotificationLevel.Notification);
|
||||
|
||||
const { container } = render(<NotificationBadge roomId="!foo:bar" notification={notif} hideIfDot={true} />);
|
||||
expect(container.querySelector(".mx_NotificationBadge_dot")).not.toBeInTheDocument();
|
||||
expect(container.querySelector(".mx_NotificationBadge")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("StatelessNotificationBadge", () => {
|
||||
it("lets you click it", () => {
|
||||
const cb = jest.fn();
|
||||
|
||||
const { getByRole } = render(
|
||||
<StatelessNotificationBadge symbol="" level={NotificationLevel.Highlight} count={5} onClick={cb} />,
|
||||
);
|
||||
|
||||
fireEvent.click(getByRole("button")!);
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("hides the bold icon when the settings is set", () => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string) => {
|
||||
return name === "feature_hidebold";
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<StatelessNotificationBadge symbol="" level={NotificationLevel.Activity} count={1} />,
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
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 React from "react";
|
||||
import { render } from "jest-matrix-react";
|
||||
|
||||
import { StatelessNotificationBadge } from "../../../../../../src/components/views/rooms/NotificationBadge/StatelessNotificationBadge";
|
||||
import { NotificationLevel } from "../../../../../../src/stores/notifications/NotificationLevel";
|
||||
|
||||
describe("StatelessNotificationBadge", () => {
|
||||
it("is highlighted when unsent", () => {
|
||||
const { container } = render(
|
||||
<StatelessNotificationBadge symbol="!" count={0} level={NotificationLevel.Unsent} />,
|
||||
);
|
||||
expect(container.querySelector(".mx_NotificationBadge_level_highlight")).not.toBe(null);
|
||||
});
|
||||
|
||||
it("has knock style", () => {
|
||||
const { container } = render(
|
||||
<StatelessNotificationBadge symbol="!" count={0} level={NotificationLevel.Highlight} knocked={true} />,
|
||||
);
|
||||
expect(container.querySelector(".mx_NotificationBadge_dot")).not.toBeInTheDocument();
|
||||
expect(container.querySelector(".mx_NotificationBadge_knocked")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has dot style for activity", () => {
|
||||
const { container } = render(
|
||||
<StatelessNotificationBadge symbol={null} count={3} level={NotificationLevel.Activity} />,
|
||||
);
|
||||
expect(container.querySelector(".mx_NotificationBadge_dot")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has badge style for notification", () => {
|
||||
const { container } = render(
|
||||
<StatelessNotificationBadge symbol={null} count={3} level={NotificationLevel.Notification} />,
|
||||
);
|
||||
expect(container.querySelector(".mx_NotificationBadge_dot")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has dot style for notification when forced", () => {
|
||||
const { container } = render(
|
||||
<StatelessNotificationBadge
|
||||
symbol={null}
|
||||
count={3}
|
||||
level={NotificationLevel.Notification}
|
||||
forceDot={true}
|
||||
/>,
|
||||
);
|
||||
expect(container.querySelector(".mx_NotificationBadge_dot")).toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,175 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022, 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 "jest-mock";
|
||||
import { screen, act, render } from "jest-matrix-react";
|
||||
import {
|
||||
MatrixEvent,
|
||||
MsgType,
|
||||
RelationType,
|
||||
NotificationCountType,
|
||||
Room,
|
||||
EventStatus,
|
||||
PendingEventOrdering,
|
||||
ReceiptType,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||
|
||||
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import { mkThread } from "../../../../../test-utils/threads";
|
||||
import { UnreadNotificationBadge } from "../../../../../../src/components/views/rooms/NotificationBadge/UnreadNotificationBadge";
|
||||
import { mkEvent, mkMessage, muteRoom, stubClient } from "../../../../../test-utils/test-utils";
|
||||
import * as RoomNotifs from "../../../../../../src/RoomNotifs";
|
||||
|
||||
const ROOM_ID = "!roomId:example.org";
|
||||
let THREAD_ID: string;
|
||||
|
||||
describe("UnreadNotificationBadge", () => {
|
||||
let client: MatrixClient;
|
||||
let room: Room;
|
||||
|
||||
function getComponent(threadId?: string) {
|
||||
return <UnreadNotificationBadge room={room} threadId={threadId} />;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
client = stubClient();
|
||||
client.supportsThreads = () => true;
|
||||
|
||||
room = new Room(ROOM_ID, client, client.getUserId()!, {
|
||||
pendingEventOrdering: PendingEventOrdering.Detached,
|
||||
});
|
||||
|
||||
const receipt = new MatrixEvent({
|
||||
type: "m.receipt",
|
||||
room_id: room.roomId,
|
||||
content: {
|
||||
"$event0:localhost": {
|
||||
[ReceiptType.Read]: {
|
||||
[client.getUserId()!]: { ts: 1, thread_id: "$otherthread:localhost" },
|
||||
},
|
||||
},
|
||||
"$event1:localhost": {
|
||||
[ReceiptType.Read]: {
|
||||
[client.getUserId()!]: { ts: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
room.addReceipt(receipt);
|
||||
|
||||
room.setUnreadNotificationCount(NotificationCountType.Total, 1);
|
||||
room.setUnreadNotificationCount(NotificationCountType.Highlight, 0);
|
||||
|
||||
const { rootEvent } = mkThread({
|
||||
room,
|
||||
client,
|
||||
authorId: client.getUserId()!,
|
||||
participantUserIds: [client.getUserId()!],
|
||||
});
|
||||
THREAD_ID = rootEvent.getId()!;
|
||||
|
||||
room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total, 1);
|
||||
room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 0);
|
||||
|
||||
jest.spyOn(RoomNotifs, "getRoomNotifsState").mockReturnValue(RoomNotifs.RoomNotifState.AllMessages);
|
||||
});
|
||||
|
||||
it("renders unread notification badge", () => {
|
||||
const { container } = render(getComponent());
|
||||
|
||||
expect(container.querySelector(".mx_NotificationBadge_visible")).toBeTruthy();
|
||||
expect(container.querySelector(".mx_NotificationBadge_level_highlight")).toBeFalsy();
|
||||
|
||||
act(() => {
|
||||
room.setUnreadNotificationCount(NotificationCountType.Highlight, 1);
|
||||
});
|
||||
|
||||
expect(container.querySelector(".mx_NotificationBadge_level_highlight")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders unread thread notification badge", () => {
|
||||
const { container } = render(getComponent(THREAD_ID));
|
||||
|
||||
expect(container.querySelector(".mx_NotificationBadge_visible")).toBeTruthy();
|
||||
expect(container.querySelector(".mx_NotificationBadge_level_highlight")).toBeFalsy();
|
||||
|
||||
act(() => {
|
||||
room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 1);
|
||||
});
|
||||
|
||||
expect(container.querySelector(".mx_NotificationBadge_level_highlight")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides unread notification badge", () => {
|
||||
act(() => {
|
||||
room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total, 0);
|
||||
room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 0);
|
||||
const { container } = render(getComponent(THREAD_ID));
|
||||
expect(container.querySelector(".mx_NotificationBadge_visible")).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
it("adds a warning for unsent messages", () => {
|
||||
const evt = mkMessage({
|
||||
room: room.roomId,
|
||||
user: "@alice:example.org",
|
||||
msg: "Hello world!",
|
||||
event: true,
|
||||
});
|
||||
evt.status = EventStatus.NOT_SENT;
|
||||
|
||||
room.addPendingEvent(evt, "123");
|
||||
|
||||
render(getComponent());
|
||||
|
||||
expect(screen.queryByText("!")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("adds a warning for invites", () => {
|
||||
room.updateMyMembership(KnownMembership.Invite);
|
||||
render(getComponent());
|
||||
expect(screen.queryByText("!")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("hides counter for muted rooms", () => {
|
||||
muteRoom(room);
|
||||
|
||||
const { container } = render(getComponent());
|
||||
expect(container.querySelector(".mx_NotificationBadge")).toBeNull();
|
||||
});
|
||||
|
||||
it("activity renders unread notification badge", () => {
|
||||
room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total, 0);
|
||||
room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 0);
|
||||
|
||||
// Add another event on the thread which is not sent by us.
|
||||
const event = mkEvent({
|
||||
event: true,
|
||||
type: "m.room.message",
|
||||
user: "@alice:server.org",
|
||||
room: room.roomId,
|
||||
content: {
|
||||
"msgtype": MsgType.Text,
|
||||
"body": "Hello from Bob",
|
||||
"m.relates_to": {
|
||||
event_id: THREAD_ID,
|
||||
rel_type: RelationType.Thread,
|
||||
},
|
||||
},
|
||||
ts: 5,
|
||||
});
|
||||
room.addLiveEvents([event]);
|
||||
|
||||
const { container } = render(getComponent(THREAD_ID));
|
||||
expect(container.querySelector(".mx_NotificationBadge_dot")).toBeTruthy();
|
||||
expect(container.querySelector(".mx_NotificationBadge_visible")).toBeTruthy();
|
||||
expect(container.querySelector(".mx_NotificationBadge_level_highlight")).toBeFalsy();
|
||||
});
|
||||
});
|
232
test/unit-tests/components/views/rooms/PinnedEventTile-test.tsx
Normal file
232
test/unit-tests/components/views/rooms/PinnedEventTile-test.tsx
Normal file
|
@ -0,0 +1,232 @@
|
|||
/*
|
||||
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 { render, screen, waitFor } from "jest-matrix-react";
|
||||
import { EventTimeline, EventType, IEvent, MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks";
|
||||
import { PinnedEventTile } from "../../../../../src/components/views/rooms/PinnedEventTile";
|
||||
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
|
||||
import { stubClient } from "../../../../test-utils";
|
||||
import dis from "../../../../../src/dispatcher/dispatcher";
|
||||
import { Action } from "../../../../../src/dispatcher/actions";
|
||||
import { getForwardableEvent } from "../../../../../src/events";
|
||||
import { createRedactEventDialog } from "../../../../../src/components/views/dialogs/ConfirmRedactDialog";
|
||||
|
||||
jest.mock("../../../../../src/components/views/dialogs/ConfirmRedactDialog", () => ({
|
||||
createRedactEventDialog: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("<PinnedEventTile />", () => {
|
||||
const userId = "@alice:server.org";
|
||||
const roomId = "!room:server.org";
|
||||
|
||||
let mockClient: MatrixClient;
|
||||
let room: Room;
|
||||
let permalinkCreator: RoomPermalinkCreator;
|
||||
beforeEach(() => {
|
||||
mockClient = stubClient();
|
||||
room = new Room(roomId, mockClient, userId);
|
||||
permalinkCreator = new RoomPermalinkCreator(room);
|
||||
mockClient.getRoom = jest.fn().mockReturnValue(room);
|
||||
jest.spyOn(dis, "dispatch").mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a pinned event with the given content.
|
||||
* @param content
|
||||
*/
|
||||
function makePinEvent(content?: Partial<IEvent>) {
|
||||
return new MatrixEvent({
|
||||
type: EventType.RoomMessage,
|
||||
sender: userId,
|
||||
content: {
|
||||
body: "First pinned message",
|
||||
msgtype: "m.text",
|
||||
},
|
||||
room_id: roomId,
|
||||
origin_server_ts: 0,
|
||||
event_id: "$eventId",
|
||||
...content,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the component with the given event.
|
||||
* @param event - pinned event
|
||||
*/
|
||||
function renderComponent(event: MatrixEvent) {
|
||||
return render(
|
||||
<MatrixClientContext.Provider value={mockClient}>
|
||||
<PinnedEventTile permalinkCreator={permalinkCreator} event={event} room={room} />
|
||||
</MatrixClientContext.Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the component and open the menu.
|
||||
*/
|
||||
async function renderAndOpenMenu() {
|
||||
const pinEvent = makePinEvent();
|
||||
const renderResult = renderComponent(pinEvent);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Open menu" }));
|
||||
return { pinEvent, renderResult };
|
||||
}
|
||||
|
||||
it("should throw when pinned event has no sender", () => {
|
||||
const pinEventWithoutSender = makePinEvent({ sender: undefined });
|
||||
expect(() => renderComponent(pinEventWithoutSender)).toThrow("Pinned event unexpectedly has no sender");
|
||||
});
|
||||
|
||||
it("should render pinned event", () => {
|
||||
const { container } = renderComponent(makePinEvent());
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should render pinned event with thread info", async () => {
|
||||
const event = makePinEvent({
|
||||
content: {
|
||||
"body": "First pinned message",
|
||||
"msgtype": "m.text",
|
||||
"m.relates_to": {
|
||||
"event_id": "$threadRootEventId",
|
||||
"is_falling_back": true,
|
||||
"m.in_reply_to": {
|
||||
event_id: "$$threadRootEventId",
|
||||
},
|
||||
"rel_type": "m.thread",
|
||||
},
|
||||
},
|
||||
});
|
||||
const threadRootEvent = makePinEvent({ event_id: "$threadRootEventId" });
|
||||
jest.spyOn(room, "findEventById").mockReturnValue(threadRootEvent);
|
||||
|
||||
const { container } = renderComponent(event);
|
||||
expect(container).toMatchSnapshot();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "thread message" }));
|
||||
// Check that the thread is opened
|
||||
expect(dis.dispatch).toHaveBeenCalledWith({
|
||||
action: Action.ShowThread,
|
||||
rootEvent: threadRootEvent,
|
||||
push: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the menu without unpin and delete", async () => {
|
||||
jest.spyOn(room.getLiveTimeline().getState(EventTimeline.FORWARDS)!, "mayClientSendStateEvent").mockReturnValue(
|
||||
false,
|
||||
);
|
||||
jest.spyOn(
|
||||
room.getLiveTimeline().getState(EventTimeline.FORWARDS)!,
|
||||
"maySendRedactionForEvent",
|
||||
).mockReturnValue(false);
|
||||
|
||||
await renderAndOpenMenu();
|
||||
|
||||
// Unpin and delete should not be present
|
||||
await waitFor(() => expect(screen.getByRole("menu")).toBeInTheDocument());
|
||||
expect(screen.getByRole("menuitem", { name: "View in timeline" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("menuitem", { name: "Forward" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("menuitem", { name: "Unpin" })).toBeNull();
|
||||
expect(screen.queryByRole("menuitem", { name: "Delete" })).toBeNull();
|
||||
expect(screen.getByRole("menu")).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should render the menu with all the options", async () => {
|
||||
// Enable unpin
|
||||
jest.spyOn(room.getLiveTimeline().getState(EventTimeline.FORWARDS)!, "mayClientSendStateEvent").mockReturnValue(
|
||||
true,
|
||||
);
|
||||
// Enable redaction
|
||||
jest.spyOn(
|
||||
room.getLiveTimeline().getState(EventTimeline.FORWARDS)!,
|
||||
"maySendRedactionForEvent",
|
||||
).mockReturnValue(true);
|
||||
|
||||
await renderAndOpenMenu();
|
||||
|
||||
await waitFor(() => expect(screen.getByRole("menu")).toBeInTheDocument());
|
||||
["View in timeline", "Forward", "Unpin", "Delete"].forEach((name) =>
|
||||
expect(screen.getByRole("menuitem", { name })).toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.getByRole("menu")).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should view in the timeline", async () => {
|
||||
const { pinEvent } = await renderAndOpenMenu();
|
||||
|
||||
// Test view in timeline button
|
||||
await userEvent.click(screen.getByRole("menuitem", { name: "View in timeline" }));
|
||||
expect(dis.dispatch).toHaveBeenCalledWith({
|
||||
action: Action.ViewRoom,
|
||||
event_id: pinEvent.getId(),
|
||||
highlighted: true,
|
||||
room_id: pinEvent.getRoomId(),
|
||||
metricsTrigger: undefined, // room doesn't change
|
||||
});
|
||||
});
|
||||
|
||||
it("should open forward dialog", async () => {
|
||||
const { pinEvent } = await renderAndOpenMenu();
|
||||
|
||||
// Test forward button
|
||||
await userEvent.click(screen.getByRole("menuitem", { name: "Forward" }));
|
||||
expect(dis.dispatch).toHaveBeenCalledWith({
|
||||
action: Action.OpenForwardDialog,
|
||||
event: getForwardableEvent(pinEvent, mockClient),
|
||||
permalinkCreator: permalinkCreator,
|
||||
});
|
||||
});
|
||||
|
||||
it("should unpin the event", async () => {
|
||||
const { pinEvent } = await renderAndOpenMenu();
|
||||
const pinEvent2 = makePinEvent({ event_id: "$eventId2" });
|
||||
|
||||
const stateEvent = {
|
||||
getContent: jest.fn().mockReturnValue({ pinned: [pinEvent.getId(), pinEvent2.getId()] }),
|
||||
} as unknown as MatrixEvent;
|
||||
|
||||
// Enable unpin
|
||||
jest.spyOn(room.getLiveTimeline().getState(EventTimeline.FORWARDS)!, "mayClientSendStateEvent").mockReturnValue(
|
||||
true,
|
||||
);
|
||||
// Mock the state event
|
||||
jest.spyOn(room.getLiveTimeline().getState(EventTimeline.FORWARDS)!, "getStateEvents").mockReturnValue(
|
||||
stateEvent,
|
||||
);
|
||||
|
||||
// Test unpin button
|
||||
await userEvent.click(screen.getByRole("menuitem", { name: "Unpin" }));
|
||||
expect(mockClient.sendStateEvent).toHaveBeenCalledWith(
|
||||
room.roomId,
|
||||
EventType.RoomPinnedEvents,
|
||||
{
|
||||
pinned: [pinEvent2.getId()],
|
||||
},
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
it("should delete the event", async () => {
|
||||
// Enable redaction
|
||||
jest.spyOn(
|
||||
room.getLiveTimeline().getState(EventTimeline.FORWARDS)!,
|
||||
"maySendRedactionForEvent",
|
||||
).mockReturnValue(true);
|
||||
|
||||
const { pinEvent } = await renderAndOpenMenu();
|
||||
|
||||
await userEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
|
||||
expect(createRedactEventDialog).toHaveBeenCalledWith({
|
||||
mxEvent: pinEvent,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,272 @@
|
|||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
* Copyright 2024 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 { act, screen, render } from "jest-matrix-react";
|
||||
import React from "react";
|
||||
import { EventType, IEvent, MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import * as pinnedEventHooks from "../../../../../src/hooks/usePinnedEvents";
|
||||
import { PinnedMessageBanner } from "../../../../../src/components/views/rooms/PinnedMessageBanner";
|
||||
import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks";
|
||||
import { makePollStartEvent, stubClient } from "../../../../test-utils";
|
||||
import dis from "../../../../../src/dispatcher/dispatcher";
|
||||
import RightPanelStore from "../../../../../src/stores/right-panel/RightPanelStore";
|
||||
import { RightPanelPhases } from "../../../../../src/stores/right-panel/RightPanelStorePhases";
|
||||
import { UPDATE_EVENT } from "../../../../../src/stores/AsyncStore";
|
||||
import { Action } from "../../../../../src/dispatcher/actions";
|
||||
|
||||
describe("<PinnedMessageBanner />", () => {
|
||||
const userId = "@alice:server.org";
|
||||
const roomId = "!room:server.org";
|
||||
|
||||
let mockClient: MatrixClient;
|
||||
let room: Room;
|
||||
let permalinkCreator: RoomPermalinkCreator;
|
||||
beforeEach(() => {
|
||||
mockClient = stubClient();
|
||||
room = new Room(roomId, mockClient, userId);
|
||||
permalinkCreator = new RoomPermalinkCreator(room);
|
||||
jest.spyOn(dis, "dispatch").mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a pinned event with the given content.
|
||||
* @param content
|
||||
*/
|
||||
function makePinEvent(content?: Partial<IEvent>) {
|
||||
return new MatrixEvent({
|
||||
type: EventType.RoomMessage,
|
||||
sender: userId,
|
||||
content: {
|
||||
body: "First pinned message",
|
||||
msgtype: "m.text",
|
||||
},
|
||||
room_id: roomId,
|
||||
origin_server_ts: 0,
|
||||
event_id: "$eventId",
|
||||
...content,
|
||||
});
|
||||
}
|
||||
|
||||
const event1 = makePinEvent();
|
||||
const event2 = makePinEvent({
|
||||
event_id: "$eventId2",
|
||||
content: { body: "Second pinned message" },
|
||||
});
|
||||
const event3 = makePinEvent({
|
||||
event_id: "$eventId3",
|
||||
content: { body: "Third pinned message" },
|
||||
});
|
||||
const event4 = makePinEvent({
|
||||
event_id: "$eventId4",
|
||||
content: { body: "Fourth pinned message" },
|
||||
});
|
||||
|
||||
/**
|
||||
* Render the banner
|
||||
*/
|
||||
function renderBanner() {
|
||||
return render(<PinnedMessageBanner permalinkCreator={permalinkCreator} room={room} />);
|
||||
}
|
||||
|
||||
it("should render nothing when there are no pinned events", async () => {
|
||||
jest.spyOn(pinnedEventHooks, "usePinnedEvents").mockReturnValue([]);
|
||||
jest.spyOn(pinnedEventHooks, "useSortedFetchedPinnedEvents").mockReturnValue([]);
|
||||
const { container } = renderBanner();
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("should render a single pinned event", async () => {
|
||||
jest.spyOn(pinnedEventHooks, "usePinnedEvents").mockReturnValue([event1.getId()!]);
|
||||
jest.spyOn(pinnedEventHooks, "useSortedFetchedPinnedEvents").mockReturnValue([event1]);
|
||||
|
||||
const { asFragment } = renderBanner();
|
||||
|
||||
expect(screen.getByText("First pinned message")).toBeVisible();
|
||||
expect(screen.queryByRole("button", { name: "View all" })).toBeNull();
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should render 2 pinned event", async () => {
|
||||
jest.spyOn(pinnedEventHooks, "usePinnedEvents").mockReturnValue([event1.getId()!, event2.getId()!]);
|
||||
jest.spyOn(pinnedEventHooks, "useSortedFetchedPinnedEvents").mockReturnValue([event1, event2]);
|
||||
|
||||
const { asFragment } = renderBanner();
|
||||
|
||||
expect(screen.getByText("Second pinned message")).toBeVisible();
|
||||
expect(screen.getByTestId("banner-counter")).toHaveTextContent("2 of 2 Pinned messages");
|
||||
expect(screen.getAllByTestId("banner-indicator")).toHaveLength(2);
|
||||
expect(screen.queryByRole("button", { name: "View all" })).toBeVisible();
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should render 4 pinned event", async () => {
|
||||
jest.spyOn(pinnedEventHooks, "usePinnedEvents").mockReturnValue([
|
||||
event1.getId()!,
|
||||
event2.getId()!,
|
||||
event3.getId()!,
|
||||
event4.getId()!,
|
||||
]);
|
||||
jest.spyOn(pinnedEventHooks, "useSortedFetchedPinnedEvents").mockReturnValue([event1, event2, event3, event4]);
|
||||
|
||||
const { asFragment } = renderBanner();
|
||||
|
||||
expect(screen.getByText("Fourth pinned message")).toBeVisible();
|
||||
expect(screen.getByTestId("banner-counter")).toHaveTextContent("4 of 4 Pinned messages");
|
||||
expect(screen.getAllByTestId("banner-indicator")).toHaveLength(3);
|
||||
expect(screen.queryByRole("button", { name: "View all" })).toBeVisible();
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should display the last message when the pinned event array changed", async () => {
|
||||
jest.spyOn(pinnedEventHooks, "usePinnedEvents").mockReturnValue([event1.getId()!, event2.getId()!]);
|
||||
jest.spyOn(pinnedEventHooks, "useSortedFetchedPinnedEvents").mockReturnValue([event1, event2]);
|
||||
|
||||
const { asFragment, rerender } = renderBanner();
|
||||
await userEvent.click(screen.getByRole("button", { name: "View the pinned message in the timeline." }));
|
||||
expect(screen.getByText("First pinned message")).toBeVisible();
|
||||
|
||||
jest.spyOn(pinnedEventHooks, "usePinnedEvents").mockReturnValue([
|
||||
event1.getId()!,
|
||||
event2.getId()!,
|
||||
event3.getId()!,
|
||||
]);
|
||||
jest.spyOn(pinnedEventHooks, "useSortedFetchedPinnedEvents").mockReturnValue([event1, event2, event3]);
|
||||
rerender(<PinnedMessageBanner permalinkCreator={permalinkCreator} room={room} />);
|
||||
expect(screen.getByText("Third pinned message")).toBeVisible();
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should rotate the pinned events when the banner is clicked", async () => {
|
||||
jest.spyOn(pinnedEventHooks, "usePinnedEvents").mockReturnValue([event1.getId()!, event2.getId()!]);
|
||||
jest.spyOn(pinnedEventHooks, "useSortedFetchedPinnedEvents").mockReturnValue([event1, event2]);
|
||||
|
||||
renderBanner();
|
||||
expect(screen.getByText("Second pinned message")).toBeVisible();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "View the pinned message in the timeline." }));
|
||||
expect(screen.getByText("First pinned message")).toBeVisible();
|
||||
expect(screen.getByTestId("banner-counter")).toHaveTextContent("1 of 2 Pinned messages");
|
||||
expect(dis.dispatch).toHaveBeenCalledWith({
|
||||
action: Action.ViewRoom,
|
||||
event_id: event2.getId(),
|
||||
highlighted: true,
|
||||
room_id: room.roomId,
|
||||
metricsTrigger: undefined, // room doesn't change
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "View the pinned message in the timeline." }));
|
||||
expect(screen.getByText("Second pinned message")).toBeVisible();
|
||||
expect(screen.getByTestId("banner-counter")).toHaveTextContent("2 of 2 Pinned messages");
|
||||
expect(dis.dispatch).toHaveBeenCalledWith({
|
||||
action: Action.ViewRoom,
|
||||
event_id: event1.getId(),
|
||||
highlighted: true,
|
||||
room_id: room.roomId,
|
||||
metricsTrigger: undefined, // room doesn't change
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["m.file", "File"],
|
||||
["m.audio", "Audio"],
|
||||
["m.video", "Video"],
|
||||
["m.image", "Image"],
|
||||
])("should display the %s event type", (msgType, label) => {
|
||||
const body = `Message with ${msgType} type`;
|
||||
const event = makePinEvent({ content: { body, msgtype: msgType } });
|
||||
jest.spyOn(pinnedEventHooks, "usePinnedEvents").mockReturnValue([event.getId()!]);
|
||||
jest.spyOn(pinnedEventHooks, "useSortedFetchedPinnedEvents").mockReturnValue([event]);
|
||||
|
||||
const { asFragment } = renderBanner();
|
||||
expect(screen.getByTestId("banner-message")).toHaveTextContent(`${label}: ${body}`);
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should display display a poll event", async () => {
|
||||
const event = makePollStartEvent("Alice?", userId);
|
||||
jest.spyOn(pinnedEventHooks, "usePinnedEvents").mockReturnValue([event.getId()!]);
|
||||
jest.spyOn(pinnedEventHooks, "useSortedFetchedPinnedEvents").mockReturnValue([event]);
|
||||
|
||||
const { asFragment } = renderBanner();
|
||||
expect(screen.getByTestId("banner-message")).toHaveTextContent("Poll: Alice?");
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe("Right button", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(pinnedEventHooks, "usePinnedEvents").mockReturnValue([event1.getId()!, event2.getId()!]);
|
||||
jest.spyOn(pinnedEventHooks, "useSortedFetchedPinnedEvents").mockReturnValue([event1, event2]);
|
||||
});
|
||||
|
||||
it("should display View all button if the right panel is closed", async () => {
|
||||
// The Right panel is closed
|
||||
jest.spyOn(RightPanelStore.instance, "isOpenForRoom").mockReturnValue(false);
|
||||
|
||||
renderBanner();
|
||||
expect(screen.getByRole("button", { name: "View all" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("should display View all button if the right panel is not opened on the pinned message list", async () => {
|
||||
// The Right panel is opened on another card
|
||||
jest.spyOn(RightPanelStore.instance, "isOpenForRoom").mockReturnValue(true);
|
||||
jest.spyOn(RightPanelStore.instance, "currentCard", "get").mockReturnValue({
|
||||
phase: RightPanelPhases.RoomMemberList,
|
||||
});
|
||||
|
||||
renderBanner();
|
||||
expect(screen.getByRole("button", { name: "View all" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("should display Close list button if the message pinning list is displayed", async () => {
|
||||
// The Right panel is closed
|
||||
jest.spyOn(RightPanelStore.instance, "isOpenForRoom").mockReturnValue(true);
|
||||
jest.spyOn(RightPanelStore.instance, "currentCard", "get").mockReturnValue({
|
||||
phase: RightPanelPhases.PinnedMessages,
|
||||
});
|
||||
|
||||
renderBanner();
|
||||
expect(screen.getByRole("button", { name: "Close list" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("should open or close the message pinning list", async () => {
|
||||
// The Right panel is closed
|
||||
jest.spyOn(RightPanelStore.instance, "isOpenForRoom").mockReturnValue(true);
|
||||
jest.spyOn(RightPanelStore.instance, "currentCard", "get").mockReturnValue({
|
||||
phase: RightPanelPhases.PinnedMessages,
|
||||
});
|
||||
jest.spyOn(RightPanelStore.instance, "showOrHidePhase").mockReturnValue();
|
||||
|
||||
renderBanner();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Close list" }));
|
||||
expect(RightPanelStore.instance.showOrHidePhase).toHaveBeenCalledWith(RightPanelPhases.PinnedMessages);
|
||||
});
|
||||
|
||||
it("should listen to the right panel", async () => {
|
||||
// The Right panel is closed
|
||||
jest.spyOn(RightPanelStore.instance, "isOpenForRoom").mockReturnValue(true);
|
||||
jest.spyOn(RightPanelStore.instance, "currentCard", "get").mockReturnValue({
|
||||
phase: RightPanelPhases.PinnedMessages,
|
||||
});
|
||||
|
||||
renderBanner();
|
||||
expect(screen.getByRole("button", { name: "Close list" })).toBeVisible();
|
||||
|
||||
jest.spyOn(RightPanelStore.instance, "isOpenForRoom").mockReturnValue(false);
|
||||
act(() => {
|
||||
RightPanelStore.instance.emit(UPDATE_EVENT);
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "View all" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
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 { render } from "jest-matrix-react";
|
||||
|
||||
import PresenceLabel from "../../../../../src/components/views/rooms/PresenceLabel";
|
||||
|
||||
describe("<PresenceLabel/>", () => {
|
||||
it("should render 'Offline' for presence=offline", () => {
|
||||
const { asFragment } = render(<PresenceLabel presenceState="offline" />);
|
||||
expect(asFragment()).toMatchInlineSnapshot(`
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="mx_PresenceLabel"
|
||||
>
|
||||
Offline
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`);
|
||||
});
|
||||
|
||||
it("should render 'Unreachable' for presence=unreachable", () => {
|
||||
const { asFragment } = render(<PresenceLabel presenceState="io.element.unreachable" />);
|
||||
expect(asFragment()).toMatchInlineSnapshot(`
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="mx_PresenceLabel"
|
||||
>
|
||||
User's server unreachable
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`);
|
||||
});
|
||||
});
|
138
test/unit-tests/components/views/rooms/ReadReceiptGroup-test.tsx
Normal file
138
test/unit-tests/components/views/rooms/ReadReceiptGroup-test.tsx
Normal file
|
@ -0,0 +1,138 @@
|
|||
/*
|
||||
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 React, { ComponentProps } from "react";
|
||||
import { render, screen, waitFor } from "jest-matrix-react";
|
||||
import { RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import {
|
||||
determineAvatarPosition,
|
||||
ReadReceiptPerson,
|
||||
readReceiptTooltip,
|
||||
} from "../../../../../src/components/views/rooms/ReadReceiptGroup";
|
||||
import * as languageHandler from "../../../../../src/languageHandler";
|
||||
import { stubClient } from "../../../../test-utils";
|
||||
import dispatcher from "../../../../../src/dispatcher/dispatcher";
|
||||
import { Action } from "../../../../../src/dispatcher/actions";
|
||||
|
||||
describe("ReadReceiptGroup", () => {
|
||||
describe("TooltipText", () => {
|
||||
it("returns '...and more' with hasMore", () => {
|
||||
expect(readReceiptTooltip(["Alice", "Bob", "Charlie", "Dan", "Eve", "Fox"], 5)).toEqual(
|
||||
"Alice, Bob, Charlie, Dan, Eve and one other",
|
||||
);
|
||||
expect(readReceiptTooltip(["Alice", "Bob", "Charlie", "Dan", "Eve", "Fox"], 4)).toEqual(
|
||||
"Alice, Bob, Charlie, Dan and 2 others",
|
||||
);
|
||||
expect(readReceiptTooltip(["Alice", "Bob", "Charlie", "Dan"], 3)).toEqual(
|
||||
"Alice, Bob, Charlie and one other",
|
||||
);
|
||||
expect(readReceiptTooltip(["Alice", "Bob", "Charlie", "Dan", "Eve", "Fox"], 2)).toEqual(
|
||||
"Alice, Bob and 4 others",
|
||||
);
|
||||
expect(readReceiptTooltip(["Alice", "Bob", "Charlie", "Dan", "Eve", "Fox"], 1)).toEqual(
|
||||
"Alice and 5 others",
|
||||
);
|
||||
expect(readReceiptTooltip([], 1)).toBe("");
|
||||
});
|
||||
it("returns a pretty list without hasMore", () => {
|
||||
jest.spyOn(languageHandler, "getUserLanguage").mockReturnValue("en-GB");
|
||||
expect(readReceiptTooltip(["Alice", "Bob", "Charlie", "Dan", "Eve"], 5)).toEqual(
|
||||
"Alice, Bob, Charlie, Dan and Eve",
|
||||
);
|
||||
expect(readReceiptTooltip(["Alice", "Bob", "Charlie", "Dan"], 4)).toEqual("Alice, Bob, Charlie and Dan");
|
||||
expect(readReceiptTooltip(["Alice", "Bob", "Charlie"], 5)).toEqual("Alice, Bob and Charlie");
|
||||
expect(readReceiptTooltip(["Alice", "Bob"], 5)).toEqual("Alice and Bob");
|
||||
expect(readReceiptTooltip(["Alice"], 5)).toEqual("Alice");
|
||||
expect(readReceiptTooltip([], 5)).toBe("");
|
||||
});
|
||||
});
|
||||
describe("AvatarPosition", () => {
|
||||
// The avatar slots are numbered from right to left
|
||||
// That means currently, we’ve got the slots | 3 | 2 | 1 | 0 | each with 10px distance to the next one.
|
||||
// We want to fill slots so the first avatar is in the right-most slot without leaving any slots at the left
|
||||
// unoccupied.
|
||||
it("to handle the non-overflowing case correctly", () => {
|
||||
expect(determineAvatarPosition(0, 4)).toEqual({ hidden: false, position: 0 });
|
||||
|
||||
expect(determineAvatarPosition(0, 4)).toEqual({ hidden: false, position: 0 });
|
||||
expect(determineAvatarPosition(1, 4)).toEqual({ hidden: false, position: 1 });
|
||||
|
||||
expect(determineAvatarPosition(0, 4)).toEqual({ hidden: false, position: 0 });
|
||||
expect(determineAvatarPosition(1, 4)).toEqual({ hidden: false, position: 1 });
|
||||
expect(determineAvatarPosition(2, 4)).toEqual({ hidden: false, position: 2 });
|
||||
|
||||
expect(determineAvatarPosition(0, 4)).toEqual({ hidden: false, position: 0 });
|
||||
expect(determineAvatarPosition(1, 4)).toEqual({ hidden: false, position: 1 });
|
||||
expect(determineAvatarPosition(2, 4)).toEqual({ hidden: false, position: 2 });
|
||||
expect(determineAvatarPosition(3, 4)).toEqual({ hidden: false, position: 3 });
|
||||
});
|
||||
|
||||
it("to handle the overflowing case correctly", () => {
|
||||
expect(determineAvatarPosition(0, 4)).toEqual({ hidden: false, position: 0 });
|
||||
expect(determineAvatarPosition(1, 4)).toEqual({ hidden: false, position: 1 });
|
||||
expect(determineAvatarPosition(2, 4)).toEqual({ hidden: false, position: 2 });
|
||||
expect(determineAvatarPosition(3, 4)).toEqual({ hidden: false, position: 3 });
|
||||
expect(determineAvatarPosition(4, 4)).toEqual({ hidden: true, position: 0 });
|
||||
expect(determineAvatarPosition(5, 4)).toEqual({ hidden: true, position: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("<ReadReceiptPerson />", () => {
|
||||
stubClient();
|
||||
|
||||
const ROOM_ID = "roomId";
|
||||
const USER_ID = "@alice:example.org";
|
||||
|
||||
const member = new RoomMember(ROOM_ID, USER_ID);
|
||||
member.rawDisplayName = "Alice";
|
||||
member.getMxcAvatarUrl = () => "http://placekitten.com/400/400";
|
||||
|
||||
const renderReadReceipt = (props?: Partial<ComponentProps<typeof ReadReceiptPerson>>) => {
|
||||
const currentDate = new Date(2024, 4, 15).getTime();
|
||||
return render(<ReadReceiptPerson userId={USER_ID} roomMember={member} ts={currentDate} {...props} />);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(dispatcher, "dispatch");
|
||||
});
|
||||
|
||||
it("should render", () => {
|
||||
const { container } = renderReadReceipt();
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should display a tooltip", async () => {
|
||||
renderReadReceipt();
|
||||
|
||||
await userEvent.hover(screen.getByRole("menuitem"));
|
||||
await waitFor(() => {
|
||||
const tooltip = screen.getByRole("tooltip");
|
||||
expect(tooltip.textContent).toMatch(new RegExp(member.rawDisplayName));
|
||||
expect(tooltip).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
it("should send an event when clicked", async () => {
|
||||
const onAfterClick = jest.fn();
|
||||
renderReadReceipt({ onAfterClick });
|
||||
|
||||
screen.getByRole("menuitem").click();
|
||||
|
||||
expect(onAfterClick).toHaveBeenCalled();
|
||||
expect(dispatcher.dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: Action.ViewUser,
|
||||
member,
|
||||
push: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2024 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 { render, screen } from "jest-matrix-react";
|
||||
|
||||
import ReadReceiptMarker, { IReadReceiptPosition } from "../../../../../src/components/views/rooms/ReadReceiptMarker";
|
||||
|
||||
describe("ReadReceiptMarker", () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("should position at -16px if given no previous position", () => {
|
||||
render(<ReadReceiptMarker fallbackUserId="bob" offset={0} />);
|
||||
|
||||
expect(screen.getByTestId("avatar-img").style.top).toBe("-16px");
|
||||
});
|
||||
|
||||
it("should position at previous top if given", () => {
|
||||
render(<ReadReceiptMarker fallbackUserId="bob" offset={0} readReceiptPosition={{ top: 100, right: 0 }} />);
|
||||
|
||||
expect(screen.getByTestId("avatar-img").style.top).toBe("100px");
|
||||
});
|
||||
|
||||
it("should apply new styles after mounted to animate", () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
render(<ReadReceiptMarker fallbackUserId="bob" offset={0} readReceiptPosition={{ top: 100, right: 0 }} />);
|
||||
expect(screen.getByTestId("avatar-img").style.top).toBe("100px");
|
||||
|
||||
jest.runAllTimers();
|
||||
|
||||
expect(screen.getByTestId("avatar-img").style.top).toBe("0px");
|
||||
});
|
||||
|
||||
it("should update readReceiptPosition when unmounted", () => {
|
||||
const pos: IReadReceiptPosition = {};
|
||||
const { unmount } = render(<ReadReceiptMarker fallbackUserId="bob" offset={0} readReceiptPosition={pos} />);
|
||||
|
||||
expect(pos.top).toBeUndefined();
|
||||
|
||||
unmount();
|
||||
|
||||
expect(pos.top).toBe(0);
|
||||
});
|
||||
|
||||
it("should update readReceiptPosition to current position", () => {
|
||||
const pos: IReadReceiptPosition = {};
|
||||
jest.spyOn(HTMLElement.prototype, "offsetParent", "get").mockImplementation(function (): Element | null {
|
||||
return {
|
||||
getBoundingClientRect: jest.fn().mockReturnValue({ top: 0, right: 0 } as DOMRect),
|
||||
} as unknown as Element;
|
||||
});
|
||||
jest.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({ top: 100, right: 0 } as DOMRect);
|
||||
|
||||
const { unmount } = render(<ReadReceiptMarker fallbackUserId="bob" offset={0} readReceiptPosition={pos} />);
|
||||
|
||||
expect(pos.top).toBeUndefined();
|
||||
|
||||
unmount();
|
||||
|
||||
expect(pos.top).toBe(100);
|
||||
});
|
||||
});
|
731
test/unit-tests/components/views/rooms/RoomHeader-test.tsx
Normal file
731
test/unit-tests/components/views/rooms/RoomHeader-test.tsx
Normal file
|
@ -0,0 +1,731 @@
|
|||
/*
|
||||
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 { CallType, MatrixCall } from "matrix-js-sdk/src/webrtc/call";
|
||||
import {
|
||||
EventType,
|
||||
JoinRule,
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
PendingEventOrdering,
|
||||
Room,
|
||||
RoomMember,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||
import {
|
||||
createEvent,
|
||||
fireEvent,
|
||||
getAllByLabelText,
|
||||
getByLabelText,
|
||||
getByText,
|
||||
queryAllByLabelText,
|
||||
queryByLabelText,
|
||||
render,
|
||||
RenderOptions,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "jest-matrix-react";
|
||||
import { ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle";
|
||||
import { mocked } from "jest-mock";
|
||||
|
||||
import { filterConsole, stubClient } from "../../../../test-utils";
|
||||
import RoomHeader from "../../../../../src/components/views/rooms/RoomHeader";
|
||||
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
|
||||
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
|
||||
import RightPanelStore from "../../../../../src/stores/right-panel/RightPanelStore";
|
||||
import { RightPanelPhases } from "../../../../../src/stores/right-panel/RightPanelStorePhases";
|
||||
import LegacyCallHandler from "../../../../../src/LegacyCallHandler";
|
||||
import SettingsStore from "../../../../../src/settings/SettingsStore";
|
||||
import SdkConfig from "../../../../../src/SdkConfig";
|
||||
import dispatcher from "../../../../../src/dispatcher/dispatcher";
|
||||
import { CallStore } from "../../../../../src/stores/CallStore";
|
||||
import { Call, ElementCall } from "../../../../../src/models/Call";
|
||||
import * as ShieldUtils from "../../../../../src/utils/ShieldUtils";
|
||||
import { Container, WidgetLayoutStore } from "../../../../../src/stores/widgets/WidgetLayoutStore";
|
||||
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
|
||||
import { _t } from "../../../../../src/languageHandler";
|
||||
import * as UseCall from "../../../../../src/hooks/useCall";
|
||||
import { SdkContextClass } from "../../../../../src/contexts/SDKContext";
|
||||
import WidgetStore, { IApp } from "../../../../../src/stores/WidgetStore";
|
||||
import { UIFeature } from "../../../../../src/settings/UIFeature";
|
||||
|
||||
jest.mock("../../../../../src/utils/ShieldUtils");
|
||||
|
||||
function getWrapper(): RenderOptions {
|
||||
return {
|
||||
wrapper: ({ children }) => (
|
||||
<MatrixClientContext.Provider value={MatrixClientPeg.safeGet()}>{children}</MatrixClientContext.Provider>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
describe("RoomHeader", () => {
|
||||
filterConsole(
|
||||
"[getType] Room !1:example.org does not have an m.room.create event",
|
||||
"Age for event was not available, using `now - origin_server_ts` as a fallback. If the device clock is not correct issues might occur.",
|
||||
);
|
||||
|
||||
let room: Room;
|
||||
const ROOM_ID = "!1:example.org";
|
||||
|
||||
let setCardSpy: jest.SpyInstance | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
stubClient();
|
||||
room = new Room(ROOM_ID, MatrixClientPeg.get()!, "@alice:example.org", {
|
||||
pendingEventOrdering: PendingEventOrdering.Detached,
|
||||
});
|
||||
DMRoomMap.setShared({
|
||||
getUserIdForRoomId: jest.fn(),
|
||||
} as unknown as DMRoomMap);
|
||||
|
||||
setCardSpy = jest.spyOn(RightPanelStore.instance, "setCard");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders the room header", () => {
|
||||
const { container } = render(<RoomHeader room={room} />, getWrapper());
|
||||
expect(container).toHaveTextContent(ROOM_ID);
|
||||
});
|
||||
|
||||
it("opens the room summary", async () => {
|
||||
const { container } = render(<RoomHeader room={room} />, getWrapper());
|
||||
|
||||
fireEvent.click(getByText(container, ROOM_ID));
|
||||
expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.RoomSummary });
|
||||
});
|
||||
|
||||
it("shows a face pile for rooms", async () => {
|
||||
const members = [
|
||||
{
|
||||
userId: "@me:example.org",
|
||||
name: "Member",
|
||||
rawDisplayName: "Member",
|
||||
roomId: room.roomId,
|
||||
membership: KnownMembership.Join,
|
||||
getAvatarUrl: () => "mxc://avatar.url/image.png",
|
||||
getMxcAvatarUrl: () => "mxc://avatar.url/image.png",
|
||||
},
|
||||
{
|
||||
userId: "@you:example.org",
|
||||
name: "Member",
|
||||
rawDisplayName: "Member",
|
||||
roomId: room.roomId,
|
||||
membership: KnownMembership.Join,
|
||||
getAvatarUrl: () => "mxc://avatar.url/image.png",
|
||||
getMxcAvatarUrl: () => "mxc://avatar.url/image.png",
|
||||
},
|
||||
{
|
||||
userId: "@them:example.org",
|
||||
name: "Member",
|
||||
rawDisplayName: "Member",
|
||||
roomId: room.roomId,
|
||||
membership: KnownMembership.Join,
|
||||
getAvatarUrl: () => "mxc://avatar.url/image.png",
|
||||
getMxcAvatarUrl: () => "mxc://avatar.url/image.png",
|
||||
},
|
||||
{
|
||||
userId: "@bot:example.org",
|
||||
name: "Bot user",
|
||||
rawDisplayName: "Bot user",
|
||||
roomId: room.roomId,
|
||||
membership: KnownMembership.Join,
|
||||
getAvatarUrl: () => "mxc://avatar.url/image.png",
|
||||
getMxcAvatarUrl: () => "mxc://avatar.url/image.png",
|
||||
},
|
||||
];
|
||||
room.currentState.setJoinedMemberCount(members.length);
|
||||
room.getJoinedMembers = jest.fn().mockReturnValue(members);
|
||||
|
||||
const { container } = render(<RoomHeader room={room} />, getWrapper());
|
||||
|
||||
expect(container).toHaveTextContent("4");
|
||||
|
||||
const facePile = getByLabelText(document.body, "4 members");
|
||||
expect(facePile).toHaveTextContent("4");
|
||||
|
||||
fireEvent.click(facePile);
|
||||
|
||||
expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.RoomMemberList });
|
||||
});
|
||||
|
||||
it("has room info icon that opens the room info panel", async () => {
|
||||
const { getAllByRole } = render(<RoomHeader room={room} />, getWrapper());
|
||||
const infoButton = getAllByRole("button", { name: "Room info" })[1];
|
||||
fireEvent.click(infoButton);
|
||||
expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.RoomSummary });
|
||||
});
|
||||
|
||||
it("opens the thread panel", async () => {
|
||||
render(<RoomHeader room={room} />, getWrapper());
|
||||
|
||||
fireEvent.click(getByLabelText(document.body, "Threads"));
|
||||
expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.ThreadPanel });
|
||||
});
|
||||
|
||||
it("opens the notifications panel", async () => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string) => {
|
||||
if (name === "feature_notifications") return true;
|
||||
});
|
||||
|
||||
render(<RoomHeader room={room} />, getWrapper());
|
||||
|
||||
fireEvent.click(getByLabelText(document.body, "Notifications"));
|
||||
expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.NotificationPanel });
|
||||
});
|
||||
|
||||
it("should show both call buttons in rooms smaller than 3 members", async () => {
|
||||
mockRoomMembers(room, 2);
|
||||
render(<RoomHeader room={room} />, getWrapper());
|
||||
|
||||
const voiceButton = screen.getByRole("button", { name: "Voice call" });
|
||||
const videoButton = screen.getByRole("button", { name: "Video call" });
|
||||
expect(videoButton).toBeInTheDocument();
|
||||
expect(voiceButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show voice call button in managed hybrid environments", async () => {
|
||||
mockRoomMembers(room, 2);
|
||||
jest.spyOn(SdkConfig, "get").mockReturnValue({ widget_build_url: "https://widget.build.url" });
|
||||
render(<RoomHeader room={room} />, getWrapper());
|
||||
|
||||
const videoButton = screen.getByRole("button", { name: "Video call" });
|
||||
expect(videoButton).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Voice call" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show voice call button in rooms larger than 2 members", async () => {
|
||||
mockRoomMembers(room, 3);
|
||||
render(<RoomHeader room={room} />, getWrapper());
|
||||
|
||||
const videoButton = screen.getByRole("button", { name: "Video call" });
|
||||
expect(videoButton).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Voice call" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("UIFeature.Widgets enabled (default)", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((feature) => feature == UIFeature.Widgets);
|
||||
});
|
||||
|
||||
it("should show call buttons in a room with 2 members", () => {
|
||||
mockRoomMembers(room, 2);
|
||||
render(<RoomHeader room={room} />, getWrapper());
|
||||
const videoButton = screen.getByRole("button", { name: "Video call" });
|
||||
expect(videoButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show call buttons in a room with more than 2 members", () => {
|
||||
mockRoomMembers(room, 3);
|
||||
render(<RoomHeader room={room} />, getWrapper());
|
||||
const videoButton = screen.getByRole("button", { name: "Video call" });
|
||||
expect(videoButton).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("UIFeature.Widgets disabled", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((feature) => false);
|
||||
});
|
||||
|
||||
it("should show call buttons in a room with 2 members", () => {
|
||||
mockRoomMembers(room, 2);
|
||||
render(<RoomHeader room={room} />, getWrapper());
|
||||
|
||||
const videoButton = screen.getByRole("button", { name: "Video call" });
|
||||
expect(videoButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show call buttons in a room with more than 2 members", () => {
|
||||
mockRoomMembers(room, 3);
|
||||
const { container } = render(<RoomHeader room={room} />, getWrapper());
|
||||
expect(queryByLabelText(container, "Video call")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("groups call disabled", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((feature) => feature == UIFeature.Widgets);
|
||||
});
|
||||
|
||||
it("you can't call if you're alone", () => {
|
||||
mockRoomMembers(room, 1);
|
||||
const { container } = render(<RoomHeader room={room} />, getWrapper());
|
||||
for (const button of getAllByLabelText(container, "There's no one here to call")) {
|
||||
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||
}
|
||||
});
|
||||
|
||||
it("you can call when you're two in the room", async () => {
|
||||
mockRoomMembers(room, 2);
|
||||
render(<RoomHeader room={room} />, getWrapper());
|
||||
|
||||
const voiceButton = screen.getByRole("button", { name: "Voice call" });
|
||||
const videoButton = screen.getByRole("button", { name: "Video call" });
|
||||
expect(voiceButton).not.toHaveAttribute("aria-disabled", "true");
|
||||
expect(videoButton).not.toHaveAttribute("aria-disabled", "true");
|
||||
|
||||
const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall");
|
||||
|
||||
fireEvent.click(voiceButton);
|
||||
expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Voice);
|
||||
|
||||
fireEvent.click(videoButton);
|
||||
expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Video);
|
||||
});
|
||||
|
||||
it("you can't call if there's already a call", () => {
|
||||
mockRoomMembers(room, 2);
|
||||
jest.spyOn(LegacyCallHandler.instance, "getCallForRoom").mockReturnValue(
|
||||
// The JS-SDK does not export the class `MatrixCall` only the type
|
||||
{} as MatrixCall,
|
||||
);
|
||||
const { container } = render(<RoomHeader room={room} />, getWrapper());
|
||||
for (const button of getAllByLabelText(container, "Ongoing call")) {
|
||||
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||
}
|
||||
});
|
||||
|
||||
it("can call in large rooms if able to edit widgets", () => {
|
||||
mockRoomMembers(room, 10);
|
||||
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true);
|
||||
render(<RoomHeader room={room} />, getWrapper());
|
||||
|
||||
const videoCallButton = screen.getByRole("button", { name: "Video call" });
|
||||
expect(videoCallButton).not.toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
|
||||
it("disable calls in large rooms by default", () => {
|
||||
mockRoomMembers(room, 10);
|
||||
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(false);
|
||||
render(<RoomHeader room={room} />, getWrapper());
|
||||
expect(
|
||||
getByLabelText(document.body, "You do not have permission to start video calls", {
|
||||
selector: "button",
|
||||
}),
|
||||
).toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("group call enabled", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation(
|
||||
(feature) => feature === "feature_group_calls" || feature == UIFeature.Widgets,
|
||||
);
|
||||
});
|
||||
|
||||
it("renders only the video call element", async () => {
|
||||
mockRoomMembers(room, 3);
|
||||
jest.spyOn(SdkConfig, "get").mockReturnValue({ use_exclusively: true });
|
||||
// allow element calls
|
||||
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true);
|
||||
|
||||
render(<RoomHeader room={room} />, getWrapper());
|
||||
|
||||
expect(screen.queryByTitle("Voice call")).toBeNull();
|
||||
|
||||
const videoCallButton = screen.getByRole("button", { name: "Video call" });
|
||||
expect(videoCallButton).not.toHaveAttribute("aria-disabled", "true");
|
||||
|
||||
const dispatcherSpy = jest.spyOn(dispatcher, "dispatch");
|
||||
|
||||
fireEvent.click(videoCallButton);
|
||||
expect(dispatcherSpy).toHaveBeenCalledWith(expect.objectContaining({ view_call: true }));
|
||||
});
|
||||
|
||||
it("can't call if there's an ongoing (pinned) call", () => {
|
||||
jest.spyOn(SdkConfig, "get").mockReturnValue({ use_exclusively: true });
|
||||
// allow element calls
|
||||
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true);
|
||||
jest.spyOn(WidgetLayoutStore.instance, "isInContainer").mockReturnValue(true);
|
||||
const widget = { type: "m.jitsi" } as IApp;
|
||||
jest.spyOn(CallStore.instance, "getCall").mockReturnValue({
|
||||
widget,
|
||||
on: () => {},
|
||||
off: () => {},
|
||||
} as unknown as Call);
|
||||
jest.spyOn(WidgetStore.instance, "getApps").mockReturnValue([widget]);
|
||||
render(<RoomHeader room={room} />, getWrapper());
|
||||
expect(screen.getByRole("button", { name: "Ongoing call" })).toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
|
||||
it("clicking on ongoing (unpinned) call re-pins it", () => {
|
||||
mockRoomMembers(room, 3);
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((feature) => feature == UIFeature.Widgets);
|
||||
// allow calls
|
||||
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true);
|
||||
jest.spyOn(WidgetLayoutStore.instance, "isInContainer").mockReturnValue(false);
|
||||
const spy = jest.spyOn(WidgetLayoutStore.instance, "moveToContainer");
|
||||
|
||||
const widget = { type: "m.jitsi" } as IApp;
|
||||
jest.spyOn(CallStore.instance, "getCall").mockReturnValue({
|
||||
widget,
|
||||
on: () => {},
|
||||
off: () => {},
|
||||
} as unknown as Call);
|
||||
jest.spyOn(WidgetStore.instance, "getApps").mockReturnValue([widget]);
|
||||
|
||||
render(<RoomHeader room={room} />, getWrapper());
|
||||
|
||||
const videoButton = screen.getByRole("button", { name: "Video call" });
|
||||
expect(videoButton).not.toHaveAttribute("aria-disabled", "true");
|
||||
fireEvent.click(videoButton);
|
||||
expect(spy).toHaveBeenCalledWith(room, widget, Container.Top);
|
||||
});
|
||||
|
||||
it("disables calling if there's a jitsi call", () => {
|
||||
mockRoomMembers(room, 2);
|
||||
jest.spyOn(LegacyCallHandler.instance, "getCallForRoom").mockReturnValue(
|
||||
// The JS-SDK does not export the class `MatrixCall` only the type
|
||||
{} as MatrixCall,
|
||||
);
|
||||
const { container } = render(<RoomHeader room={room} />, getWrapper());
|
||||
for (const button of getAllByLabelText(container, "Ongoing call")) {
|
||||
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||
}
|
||||
});
|
||||
|
||||
it("can't call if you have no friends and cannot invite friends", () => {
|
||||
mockRoomMembers(room, 1);
|
||||
const { container } = render(<RoomHeader room={room} />, getWrapper());
|
||||
for (const button of getAllByLabelText(container, "There's no one here to call")) {
|
||||
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||
}
|
||||
});
|
||||
|
||||
it("can call if you have no friends but can invite friends", () => {
|
||||
mockRoomMembers(room, 1);
|
||||
// go through all the different `canInvite` and `getJoinRule` combinations
|
||||
|
||||
// check where we can't do anything but can upgrade
|
||||
jest.spyOn(room.currentState, "maySendStateEvent").mockReturnValue(true);
|
||||
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Invite);
|
||||
jest.spyOn(room, "canInvite").mockReturnValue(false);
|
||||
const guestSpaUrlMock = jest.spyOn(SdkConfig, "get").mockImplementation((key) => {
|
||||
return { guest_spa_url: "https://guest_spa_url.com", url: "https://spa_url.com" };
|
||||
});
|
||||
const { container: containerNoInviteNotPublicCanUpgradeAccess } = render(
|
||||
<RoomHeader room={room} />,
|
||||
getWrapper(),
|
||||
);
|
||||
expect(
|
||||
queryAllByLabelText(containerNoInviteNotPublicCanUpgradeAccess, "There's no one here to call"),
|
||||
).toHaveLength(0);
|
||||
|
||||
// dont allow upgrading anymore and go through the other combinations
|
||||
jest.spyOn(room.currentState, "maySendStateEvent").mockReturnValue(false);
|
||||
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Invite);
|
||||
jest.spyOn(room, "canInvite").mockReturnValue(false);
|
||||
jest.spyOn(SdkConfig, "get").mockImplementation((key) => {
|
||||
return { guest_spa_url: "https://guest_spa_url.com", url: "https://spa_url.com" };
|
||||
});
|
||||
const { container: containerNoInviteNotPublic } = render(<RoomHeader room={room} />, getWrapper());
|
||||
expect(queryAllByLabelText(containerNoInviteNotPublic, "There's no one here to call")).toHaveLength(2);
|
||||
|
||||
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Knock);
|
||||
jest.spyOn(room, "canInvite").mockReturnValue(false);
|
||||
const { container: containerNoInvitePublic } = render(<RoomHeader room={room} />, getWrapper());
|
||||
expect(queryAllByLabelText(containerNoInvitePublic, "There's no one here to call")).toHaveLength(2);
|
||||
|
||||
jest.spyOn(room, "canInvite").mockReturnValue(true);
|
||||
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Invite);
|
||||
const { container: containerInviteNotPublic } = render(<RoomHeader room={room} />, getWrapper());
|
||||
expect(queryAllByLabelText(containerInviteNotPublic, "There's no one here to call")).toHaveLength(2);
|
||||
|
||||
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Knock);
|
||||
jest.spyOn(room, "canInvite").mockReturnValue(true);
|
||||
const { container: containerInvitePublic } = render(<RoomHeader room={room} />, getWrapper());
|
||||
expect(queryAllByLabelText(containerInvitePublic, "There's no one here to call")).toHaveLength(0);
|
||||
|
||||
// last we can allow everything but without guest_spa_url nothing will work
|
||||
guestSpaUrlMock.mockRestore();
|
||||
const { container: containerAllAllowedButNoGuestSpaUrl } = render(<RoomHeader room={room} />, getWrapper());
|
||||
expect(
|
||||
queryAllByLabelText(containerAllAllowedButNoGuestSpaUrl, "There's no one here to call"),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("calls using legacy or jitsi", async () => {
|
||||
mockRoomMembers(room, 2);
|
||||
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockImplementation((key) => {
|
||||
if (key === "im.vector.modular.widgets") return true;
|
||||
return false;
|
||||
});
|
||||
render(<RoomHeader room={room} />, getWrapper());
|
||||
|
||||
const voiceButton = screen.getByRole("button", { name: "Voice call" });
|
||||
const videoButton = screen.getByRole("button", { name: "Video call" });
|
||||
expect(voiceButton).not.toHaveAttribute("aria-disabled", "true");
|
||||
expect(videoButton).not.toHaveAttribute("aria-disabled", "true");
|
||||
|
||||
const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall");
|
||||
fireEvent.click(voiceButton);
|
||||
expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Voice);
|
||||
|
||||
fireEvent.click(videoButton);
|
||||
expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Video);
|
||||
});
|
||||
|
||||
it("calls using legacy or jitsi for large rooms", async () => {
|
||||
mockRoomMembers(room, 3);
|
||||
|
||||
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockImplementation((key) => {
|
||||
if (key === "im.vector.modular.widgets") return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
render(<RoomHeader room={room} />, getWrapper());
|
||||
|
||||
const videoButton = screen.getByRole("button", { name: "Video call" });
|
||||
expect(videoButton).not.toHaveAttribute("aria-disabled", "true");
|
||||
|
||||
const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall");
|
||||
fireEvent.click(videoButton);
|
||||
expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Video);
|
||||
});
|
||||
|
||||
it("calls using element call for large rooms", async () => {
|
||||
mockRoomMembers(room, 3);
|
||||
|
||||
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockImplementation((key) => {
|
||||
if (key === ElementCall.MEMBER_EVENT_TYPE.name) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
render(<RoomHeader room={room} />, getWrapper());
|
||||
|
||||
const videoButton = screen.getByRole("button", { name: "Video call" });
|
||||
expect(videoButton).not.toHaveAttribute("aria-disabled", "true");
|
||||
|
||||
const dispatcherSpy = jest.spyOn(dispatcher, "dispatch");
|
||||
fireEvent.click(videoButton);
|
||||
expect(dispatcherSpy).toHaveBeenCalledWith(expect.objectContaining({ view_call: true }));
|
||||
});
|
||||
|
||||
it("buttons are disabled if there is an ongoing call", async () => {
|
||||
mockRoomMembers(room, 3);
|
||||
|
||||
jest.spyOn(CallStore.prototype, "connectedCalls", "get").mockReturnValue(
|
||||
new Set([{ roomId: "some_other_room" } as Call]),
|
||||
);
|
||||
const { container } = render(<RoomHeader room={room} />, getWrapper());
|
||||
|
||||
const [videoButton] = getAllByLabelText(container, "Ongoing call");
|
||||
|
||||
expect(videoButton).toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
|
||||
it("join button is shown if there is an ongoing call", async () => {
|
||||
mockRoomMembers(room, 3);
|
||||
jest.spyOn(UseCall, "useParticipantCount").mockReturnValue(3);
|
||||
render(<RoomHeader room={room} />, getWrapper());
|
||||
const joinButton = getByLabelText(document.body, "Join");
|
||||
expect(joinButton).not.toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
|
||||
it("join button is disabled if there is an other ongoing call", async () => {
|
||||
mockRoomMembers(room, 3);
|
||||
jest.spyOn(UseCall, "useParticipantCount").mockReturnValue(3);
|
||||
jest.spyOn(CallStore.prototype, "connectedCalls", "get").mockReturnValue(
|
||||
new Set([{ roomId: "some_other_room" } as Call]),
|
||||
);
|
||||
render(<RoomHeader room={room} />, getWrapper());
|
||||
const joinButton = getByLabelText(document.body, "Ongoing call");
|
||||
|
||||
expect(joinButton).toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
|
||||
it("close lobby button is shown", async () => {
|
||||
mockRoomMembers(room, 3);
|
||||
|
||||
jest.spyOn(SdkContextClass.instance.roomViewStore, "isViewingCall").mockReturnValue(true);
|
||||
render(<RoomHeader room={room} />, getWrapper());
|
||||
getByLabelText(document.body, "Close lobby");
|
||||
});
|
||||
|
||||
it("close lobby button is shown if there is an ongoing call but we are viewing the lobby", async () => {
|
||||
mockRoomMembers(room, 3);
|
||||
jest.spyOn(UseCall, "useParticipantCount").mockReturnValue(3);
|
||||
jest.spyOn(SdkContextClass.instance.roomViewStore, "isViewingCall").mockReturnValue(true);
|
||||
|
||||
render(<RoomHeader room={room} />, getWrapper());
|
||||
getByLabelText(document.body, "Close lobby");
|
||||
});
|
||||
|
||||
it("don't show external conference button if the call is not shown", () => {
|
||||
jest.spyOn(SdkContextClass.instance.roomViewStore, "isViewingCall").mockReturnValue(false);
|
||||
jest.spyOn(SdkConfig, "get").mockImplementation((key) => {
|
||||
return { guest_spa_url: "https://guest_spa_url.com", url: "https://spa_url.com" };
|
||||
});
|
||||
render(<RoomHeader room={room} />, getWrapper());
|
||||
expect(screen.queryByLabelText(_t("voip|get_call_link"))).not.toBeInTheDocument();
|
||||
|
||||
jest.spyOn(SdkContextClass.instance.roomViewStore, "isViewingCall").mockReturnValue(true);
|
||||
|
||||
render(<RoomHeader room={room} />, getWrapper()).container;
|
||||
|
||||
expect(getByLabelText(document.body, _t("voip|get_call_link"))).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("public room", () => {
|
||||
it("shows a globe", () => {
|
||||
const joinRuleEvent = new MatrixEvent({
|
||||
type: EventType.RoomJoinRules,
|
||||
content: { join_rule: JoinRule.Public },
|
||||
sender: MatrixClientPeg.get()!.getSafeUserId(),
|
||||
state_key: "",
|
||||
room_id: room.roomId,
|
||||
});
|
||||
room.addLiveEvents([joinRuleEvent]);
|
||||
|
||||
render(<RoomHeader room={room} />, getWrapper());
|
||||
|
||||
expect(getByLabelText(document.body, "Public room")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("dm", () => {
|
||||
let client: MatrixClient;
|
||||
beforeEach(() => {
|
||||
client = MatrixClientPeg.get()!;
|
||||
|
||||
// Make the mocked room a DM
|
||||
mocked(DMRoomMap.shared().getUserIdForRoomId).mockImplementation((roomId) => {
|
||||
if (roomId === room.roomId) return "@user:example.com";
|
||||
});
|
||||
room.getMember = jest.fn((userId) => new RoomMember(room.roomId, userId));
|
||||
room.getJoinedMembers = jest.fn().mockReturnValue([
|
||||
{
|
||||
userId: "@me:example.org",
|
||||
name: "Member",
|
||||
rawDisplayName: "Member",
|
||||
roomId: room.roomId,
|
||||
membership: KnownMembership.Join,
|
||||
getAvatarUrl: () => "mxc://avatar.url/image.png",
|
||||
getMxcAvatarUrl: () => "mxc://avatar.url/image.png",
|
||||
},
|
||||
{
|
||||
userId: "@bob:example.org",
|
||||
name: "Other Member",
|
||||
rawDisplayName: "Other Member",
|
||||
roomId: room.roomId,
|
||||
membership: KnownMembership.Join,
|
||||
getAvatarUrl: () => "mxc://avatar.url/image.png",
|
||||
getMxcAvatarUrl: () => "mxc://avatar.url/image.png",
|
||||
},
|
||||
]);
|
||||
jest.spyOn(client, "isCryptoEnabled").mockReturnValue(true);
|
||||
jest.spyOn(ShieldUtils, "shieldStatusForRoom").mockResolvedValue(ShieldUtils.E2EStatus.Normal);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[ShieldUtils.E2EStatus.Verified, "Verified"],
|
||||
[ShieldUtils.E2EStatus.Warning, "Untrusted"],
|
||||
])("shows the %s icon", async (value: ShieldUtils.E2EStatus, expectedLabel: string) => {
|
||||
jest.spyOn(ShieldUtils, "shieldStatusForRoom").mockResolvedValue(value);
|
||||
|
||||
render(<RoomHeader room={room} />, getWrapper());
|
||||
|
||||
await waitFor(() => expect(getByLabelText(document.body, expectedLabel)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("does not show the face pile for DMs", () => {
|
||||
const { asFragment } = render(<RoomHeader room={room} />, getWrapper());
|
||||
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders additionalButtons", async () => {
|
||||
const additionalButtons: ViewRoomOpts["buttons"] = [
|
||||
{
|
||||
icon: () => <>test-icon</>,
|
||||
id: "test-id",
|
||||
label: () => "test-label",
|
||||
onClick: () => {},
|
||||
},
|
||||
];
|
||||
render(<RoomHeader room={room} additionalButtons={additionalButtons} />, getWrapper());
|
||||
expect(screen.getByRole("button", { name: "test-label" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onClick-callback on additionalButtons", () => {
|
||||
const callback = jest.fn();
|
||||
const additionalButtons: ViewRoomOpts["buttons"] = [
|
||||
{
|
||||
icon: () => <>test-icon</>,
|
||||
id: "test-id",
|
||||
label: () => "test-label",
|
||||
onClick: callback,
|
||||
},
|
||||
];
|
||||
|
||||
render(<RoomHeader room={room} additionalButtons={additionalButtons} />, getWrapper());
|
||||
|
||||
const button = screen.getByRole("button", { name: "test-label" });
|
||||
const event = createEvent.click(button);
|
||||
event.stopPropagation = jest.fn();
|
||||
fireEvent(button, event);
|
||||
|
||||
expect(callback).toHaveBeenCalled();
|
||||
expect(event.stopPropagation).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("ask to join disabled", () => {
|
||||
it("does not render the RoomKnocksBar", () => {
|
||||
render(<RoomHeader room={room} />, getWrapper());
|
||||
expect(screen.queryByRole("heading", { name: "Asking to join" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ask to join enabled", () => {
|
||||
it("does render the RoomKnocksBar", () => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((feature) => feature === "feature_ask_to_join");
|
||||
jest.spyOn(room, "canInvite").mockReturnValue(true);
|
||||
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Knock);
|
||||
jest.spyOn(room, "getMembersWithMembership").mockReturnValue([new RoomMember(room.roomId, "@foo")]);
|
||||
|
||||
render(<RoomHeader room={room} />, getWrapper());
|
||||
expect(screen.getByRole("heading", { name: "Asking to join" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should open room settings when clicking the room avatar", async () => {
|
||||
render(<RoomHeader room={room} />, getWrapper());
|
||||
|
||||
const dispatcherSpy = jest.spyOn(dispatcher, "dispatch");
|
||||
fireEvent.click(getByLabelText(document.body, "Open room settings"));
|
||||
expect(dispatcherSpy).toHaveBeenCalledWith(expect.objectContaining({ action: "open_room_settings" }));
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
* @param count the number of users to create
|
||||
*/
|
||||
function mockRoomMembers(room: Room, count: number) {
|
||||
const members = Array(count)
|
||||
.fill(0)
|
||||
.map((_, index) => ({
|
||||
userId: `@user-${index}:example.org`,
|
||||
name: `Member ${index}`,
|
||||
rawDisplayName: `Member ${index}`,
|
||||
roomId: room.roomId,
|
||||
membership: KnownMembership.Join,
|
||||
getAvatarUrl: () => `mxc://avatar.url/user-${index}.png`,
|
||||
getMxcAvatarUrl: () => `mxc://avatar.url/user-${index}.png`,
|
||||
}));
|
||||
|
||||
room.currentState.setJoinedMemberCount(members.length);
|
||||
room.getJoinedMembers = jest.fn().mockReturnValue(members);
|
||||
}
|
|
@ -0,0 +1,273 @@
|
|||
/*
|
||||
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, getByLabelText, getByText, render, screen, waitFor } from "jest-matrix-react";
|
||||
import { EventTimeline, JoinRule, Room } from "matrix-js-sdk/src/matrix";
|
||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||
|
||||
import { SDKContext, SdkContextClass } from "../../../../../../src/contexts/SDKContext";
|
||||
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../../../../test-utils";
|
||||
import {
|
||||
CallGuestLinkButton,
|
||||
JoinRuleDialog,
|
||||
} from "../../../../../../src/components/views/rooms/RoomHeader/CallGuestLinkButton";
|
||||
import Modal from "../../../../../../src/Modal";
|
||||
import SdkConfig from "../../../../../../src/SdkConfig";
|
||||
import ShareDialog from "../../../../../../src/components/views/dialogs/ShareDialog";
|
||||
import { _t } from "../../../../../../src/languageHandler";
|
||||
import SettingsStore from "../../../../../../src/settings/SettingsStore";
|
||||
|
||||
describe("<CallGuestLinkButton />", () => {
|
||||
const roomId = "!room:server.org";
|
||||
let sdkContext!: SdkContextClass;
|
||||
let modalSpy: jest.SpyInstance;
|
||||
let modalResolve: (value: unknown[] | PromiseLike<unknown[]>) => void;
|
||||
let room: Room;
|
||||
|
||||
const targetUnencrypted =
|
||||
"https://guest_spa_url.com/room/#/!room:server.org?roomId=%21room%3Aserver.org&viaServers=example.org";
|
||||
const targetEncrypted =
|
||||
"https://guest_spa_url.com/room/#/!room:server.org?roomId=%21room%3Aserver.org&perParticipantE2EE=true&viaServers=example.org";
|
||||
const expectedShareDialogProps = {
|
||||
target: targetEncrypted,
|
||||
customTitle: "Conference invite link",
|
||||
subtitle: "Link for external users to join the call without a matrix account:",
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a room using mocked client
|
||||
* And mock isElementVideoRoom
|
||||
*/
|
||||
const makeRoom = (isVideoRoom = true): Room => {
|
||||
const room = new Room(roomId, sdkContext.client!, sdkContext.client!.getSafeUserId());
|
||||
jest.spyOn(room, "isElementVideoRoom").mockReturnValue(isVideoRoom);
|
||||
// stub
|
||||
jest.spyOn(room, "getPendingEvents").mockReturnValue([]);
|
||||
return room;
|
||||
};
|
||||
function mockRoomMembers(room: Room, count: number) {
|
||||
const members = Array(count)
|
||||
.fill(0)
|
||||
.map((_, index) => ({
|
||||
userId: `@user-${index}:example.org`,
|
||||
roomId: room.roomId,
|
||||
membership: KnownMembership.Join,
|
||||
}));
|
||||
|
||||
room.currentState.setJoinedMemberCount(members.length);
|
||||
room.getJoinedMembers = jest.fn().mockReturnValue(members);
|
||||
}
|
||||
|
||||
const getComponent = (room: Room) =>
|
||||
render(<CallGuestLinkButton room={room} />, {
|
||||
wrapper: ({ children }) => <SDKContext.Provider value={sdkContext}>{children}</SDKContext.Provider>,
|
||||
});
|
||||
|
||||
const oldGet = SdkConfig.get;
|
||||
beforeEach(() => {
|
||||
const client = getMockClientWithEventEmitter({
|
||||
...mockClientMethodsUser(),
|
||||
sendStateEvent: jest.fn(),
|
||||
});
|
||||
sdkContext = new SdkContextClass();
|
||||
sdkContext.client = client;
|
||||
const modalPromise = new Promise<unknown[]>((resolve) => {
|
||||
modalResolve = resolve;
|
||||
});
|
||||
modalSpy = jest.spyOn(Modal, "createDialog").mockReturnValue({ finished: modalPromise, close: jest.fn() });
|
||||
room = makeRoom();
|
||||
mockRoomMembers(room, 3);
|
||||
|
||||
jest.spyOn(SdkConfig, "get").mockImplementation((key) => {
|
||||
if (key === "element_call") {
|
||||
return { guest_spa_url: "https://guest_spa_url.com", url: "https://spa_url.com" };
|
||||
}
|
||||
return oldGet(key);
|
||||
});
|
||||
jest.spyOn(room, "hasEncryptionStateEvent").mockReturnValue(true);
|
||||
jest.spyOn(SdkContextClass.instance.roomViewStore, "isViewingCall").mockReturnValue(true);
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("shows the JoinRuleDialog on click with private join rules", async () => {
|
||||
getComponent(room);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Share call link" }));
|
||||
expect(modalSpy).toHaveBeenCalledWith(JoinRuleDialog, { room, canInvite: false });
|
||||
// pretend public was selected
|
||||
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Public);
|
||||
modalResolve([]);
|
||||
await new Promise(process.nextTick);
|
||||
const callParams = modalSpy.mock.calls[1];
|
||||
expect(callParams[0]).toEqual(ShareDialog);
|
||||
expect(callParams[1].target.toString()).toEqual(expectedShareDialogProps.target);
|
||||
expect(callParams[1].subtitle).toEqual(expectedShareDialogProps.subtitle);
|
||||
expect(callParams[1].customTitle).toEqual(expectedShareDialogProps.customTitle);
|
||||
});
|
||||
|
||||
it("shows the ShareDialog on click with public join rules", () => {
|
||||
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Public);
|
||||
getComponent(room);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Share call link" }));
|
||||
const callParams = modalSpy.mock.calls[0];
|
||||
expect(callParams[0]).toEqual(ShareDialog);
|
||||
expect(callParams[1].target.toString()).toEqual(expectedShareDialogProps.target);
|
||||
expect(callParams[1].subtitle).toEqual(expectedShareDialogProps.subtitle);
|
||||
expect(callParams[1].customTitle).toEqual(expectedShareDialogProps.customTitle);
|
||||
});
|
||||
|
||||
it("shows the ShareDialog on click with knock join rules", () => {
|
||||
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Knock);
|
||||
jest.spyOn(room, "canInvite").mockReturnValue(true);
|
||||
getComponent(room);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Share call link" }));
|
||||
const callParams = modalSpy.mock.calls[0];
|
||||
expect(callParams[0]).toEqual(ShareDialog);
|
||||
expect(callParams[1].target.toString()).toEqual(expectedShareDialogProps.target);
|
||||
expect(callParams[1].subtitle).toEqual(expectedShareDialogProps.subtitle);
|
||||
expect(callParams[1].customTitle).toEqual(expectedShareDialogProps.customTitle);
|
||||
});
|
||||
|
||||
it("don't show external conference button if room not public nor knock and the user cannot change join rules", () => {
|
||||
// preparation for if we refactor the related code to not use currentState.
|
||||
jest.spyOn(room, "getLiveTimeline").mockReturnValue({
|
||||
getState: jest.fn().mockReturnValue({
|
||||
maySendStateEvent: jest.fn().mockReturnValue(false),
|
||||
}),
|
||||
} as unknown as EventTimeline);
|
||||
jest.spyOn(room.currentState, "maySendStateEvent").mockReturnValue(false);
|
||||
getComponent(room);
|
||||
expect(screen.queryByLabelText("Share call link")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("don't show external conference button if now guest spa link is configured", () => {
|
||||
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Public);
|
||||
jest.spyOn(SdkContextClass.instance.roomViewStore, "isViewingCall").mockReturnValue(true);
|
||||
|
||||
jest.spyOn(SdkConfig, "get").mockImplementation((key) => {
|
||||
if (key === "element_call") {
|
||||
return { url: "https://example2.com" };
|
||||
}
|
||||
return oldGet(key);
|
||||
});
|
||||
|
||||
getComponent(room);
|
||||
// We only change the SdkConfig and show that this everything else is
|
||||
// configured so that the call link button is shown.
|
||||
expect(screen.queryByLabelText("Share call link")).not.toBeInTheDocument();
|
||||
|
||||
jest.spyOn(SdkConfig, "get").mockImplementation((key) => {
|
||||
if (key === "element_call") {
|
||||
return { guest_spa_url: "https://guest_spa_url.com", url: "https://example2.com" };
|
||||
}
|
||||
return oldGet(key);
|
||||
});
|
||||
|
||||
getComponent(room);
|
||||
expect(getByLabelText(document.body, "Share call link")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the share dialog with the correct share link in an encrypted room", () => {
|
||||
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Public);
|
||||
jest.spyOn(SdkContextClass.instance.roomViewStore, "isViewingCall").mockReturnValue(true);
|
||||
|
||||
getComponent(room);
|
||||
const modalSpy = jest.spyOn(Modal, "createDialog");
|
||||
fireEvent.click(getByLabelText(document.body, _t("voip|get_call_link")));
|
||||
// const target =
|
||||
// "https://guest_spa_url.com/room/#/!room:server.org?roomId=%21room%3Aserver.org&perParticipantE2EE=true&viaServers=example.org";
|
||||
expect(modalSpy).toHaveBeenCalled();
|
||||
const arg0 = modalSpy.mock.calls[0][0];
|
||||
const arg1 = modalSpy.mock.calls[0][1] as any;
|
||||
expect(arg0).toEqual(ShareDialog);
|
||||
const { customTitle, subtitle } = arg1;
|
||||
expect({ customTitle, subtitle }).toEqual({
|
||||
customTitle: "Conference invite link",
|
||||
subtitle: _t("share|share_call_subtitle"),
|
||||
});
|
||||
expect(arg1.target.toString()).toEqual(targetEncrypted);
|
||||
});
|
||||
|
||||
it("share dialog has correct link in an unencrypted room", () => {
|
||||
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Public);
|
||||
jest.spyOn(room, "hasEncryptionStateEvent").mockReturnValue(false);
|
||||
jest.spyOn(SdkContextClass.instance.roomViewStore, "isViewingCall").mockReturnValue(true);
|
||||
|
||||
getComponent(room);
|
||||
const modalSpy = jest.spyOn(Modal, "createDialog");
|
||||
fireEvent.click(getByLabelText(document.body, _t("voip|get_call_link")));
|
||||
const arg1 = modalSpy.mock.calls[0][1] as any;
|
||||
expect(arg1.target.toString()).toEqual(targetUnencrypted);
|
||||
});
|
||||
|
||||
describe("<JoinRuleDialog />", () => {
|
||||
const onFinished = jest.fn();
|
||||
|
||||
const getComponent = (room: Room, canInvite: boolean = true) =>
|
||||
render(<JoinRuleDialog room={room} canInvite={canInvite} onFinished={onFinished} />, {
|
||||
wrapper: ({ children }) => <SDKContext.Provider value={sdkContext}>{children}</SDKContext.Provider>,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// feature_ask_to_join enabled
|
||||
jest.spyOn(SettingsStore, "getValue").mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("shows ask to join if feature is enabled", () => {
|
||||
const { container } = getComponent(room);
|
||||
expect(getByText(container, "Ask to join")).toBeInTheDocument();
|
||||
});
|
||||
it("font show ask to join if feature is enabled but cannot invite", () => {
|
||||
getComponent(room, false);
|
||||
expect(screen.queryByText("Ask to join")).not.toBeInTheDocument();
|
||||
});
|
||||
it("doesn't show ask to join if feature is disabled", () => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
|
||||
getComponent(room);
|
||||
expect(screen.queryByText("Ask to join")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sends correct state event on click", async () => {
|
||||
const sendStateSpy = jest.spyOn(sdkContext.client!, "sendStateEvent");
|
||||
let container;
|
||||
container = getComponent(room).container;
|
||||
fireEvent.click(getByText(container, "Ask to join"));
|
||||
expect(sendStateSpy).toHaveBeenCalledWith(
|
||||
"!room:server.org",
|
||||
"m.room.join_rules",
|
||||
{ join_rule: "knock" },
|
||||
"",
|
||||
);
|
||||
expect(sendStateSpy).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => expect(onFinished).toHaveBeenCalledTimes(1));
|
||||
onFinished.mockClear();
|
||||
sendStateSpy.mockClear();
|
||||
|
||||
container = getComponent(room).container;
|
||||
fireEvent.click(getByText(container, "Public"));
|
||||
expect(sendStateSpy).toHaveBeenLastCalledWith(
|
||||
"!room:server.org",
|
||||
"m.room.join_rules",
|
||||
{ join_rule: "public" },
|
||||
"",
|
||||
);
|
||||
expect(sendStateSpy).toHaveBeenCalledTimes(1);
|
||||
container = getComponent(room).container;
|
||||
await waitFor(() => expect(onFinished).toHaveBeenCalledTimes(1));
|
||||
onFinished.mockClear();
|
||||
sendStateSpy.mockClear();
|
||||
|
||||
fireEvent.click(getByText(container, _t("update_room_access_modal|no_change")));
|
||||
await waitFor(() => expect(onFinished).toHaveBeenCalledTimes(1));
|
||||
// Don't call sendStateEvent if no change is clicked.
|
||||
expect(sendStateSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,126 @@
|
|||
/*
|
||||
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 { MockedObject } from "jest-mock";
|
||||
import { Room } from "matrix-js-sdk/src/matrix";
|
||||
import { fireEvent, render, screen, waitFor } from "jest-matrix-react";
|
||||
|
||||
import { VideoRoomChatButton } from "../../../../../../src/components/views/rooms/RoomHeader/VideoRoomChatButton";
|
||||
import { SDKContext, SdkContextClass } from "../../../../../../src/contexts/SDKContext";
|
||||
import RightPanelStore from "../../../../../../src/stores/right-panel/RightPanelStore";
|
||||
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../../../../test-utils";
|
||||
import { RoomNotificationState } from "../../../../../../src/stores/notifications/RoomNotificationState";
|
||||
import { NotificationLevel } from "../../../../../../src/stores/notifications/NotificationLevel";
|
||||
import { NotificationStateEvents } from "../../../../../../src/stores/notifications/NotificationState";
|
||||
import { RightPanelPhases } from "../../../../../../src/stores/right-panel/RightPanelStorePhases";
|
||||
|
||||
describe("<VideoRoomChatButton />", () => {
|
||||
const roomId = "!room:server.org";
|
||||
let sdkContext!: SdkContextClass;
|
||||
let rightPanelStore!: MockedObject<RightPanelStore>;
|
||||
|
||||
/**
|
||||
* Create a room using mocked client
|
||||
* And mock isElementVideoRoom
|
||||
*/
|
||||
const makeRoom = (isVideoRoom = true): Room => {
|
||||
const room = new Room(roomId, sdkContext.client!, sdkContext.client!.getSafeUserId());
|
||||
jest.spyOn(room, "isElementVideoRoom").mockReturnValue(isVideoRoom);
|
||||
// stub
|
||||
jest.spyOn(room, "getPendingEvents").mockReturnValue([]);
|
||||
return room;
|
||||
};
|
||||
|
||||
const mockRoomNotificationState = (room: Room, level: NotificationLevel): RoomNotificationState => {
|
||||
const roomNotificationState = new RoomNotificationState(room, false);
|
||||
|
||||
// @ts-ignore ugly mocking
|
||||
roomNotificationState._level = level;
|
||||
jest.spyOn(sdkContext.roomNotificationStateStore, "getRoomState").mockReturnValue(roomNotificationState);
|
||||
return roomNotificationState;
|
||||
};
|
||||
|
||||
const getComponent = (room: Room) =>
|
||||
render(<VideoRoomChatButton room={room} />, {
|
||||
wrapper: ({ children }) => <SDKContext.Provider value={sdkContext}>{children}</SDKContext.Provider>,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
const client = getMockClientWithEventEmitter({
|
||||
...mockClientMethodsUser(),
|
||||
});
|
||||
rightPanelStore = {
|
||||
showOrHidePhase: jest.fn(),
|
||||
} as unknown as MockedObject<RightPanelStore>;
|
||||
sdkContext = new SdkContextClass();
|
||||
sdkContext.client = client;
|
||||
jest.spyOn(sdkContext, "rightPanelStore", "get").mockReturnValue(rightPanelStore);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("toggles timeline in right panel on click", () => {
|
||||
const room = makeRoom();
|
||||
getComponent(room);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Chat" }));
|
||||
|
||||
expect(sdkContext.rightPanelStore.showOrHidePhase).toHaveBeenCalledWith(RightPanelPhases.Timeline);
|
||||
});
|
||||
|
||||
it("renders button with an unread marker when room is unread", () => {
|
||||
const room = makeRoom();
|
||||
mockRoomNotificationState(room, NotificationLevel.Activity);
|
||||
getComponent(room);
|
||||
|
||||
// snapshot includes `data-indicator` attribute
|
||||
expect(screen.getByRole("button", { name: "Chat" })).toMatchSnapshot();
|
||||
expect(screen.getByRole("button", { name: "Chat" }).hasAttribute("data-indicator")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("adds unread marker when room notification state changes to unread", async () => {
|
||||
const room = makeRoom();
|
||||
// start in read state
|
||||
const notificationState = mockRoomNotificationState(room, NotificationLevel.None);
|
||||
getComponent(room);
|
||||
|
||||
// no unread marker
|
||||
expect(screen.getByRole("button", { name: "Chat" }).hasAttribute("data-indicator")).toBeFalsy();
|
||||
|
||||
// @ts-ignore ugly mocking
|
||||
notificationState._level = NotificationLevel.Highlight;
|
||||
notificationState.emit(NotificationStateEvents.Update);
|
||||
|
||||
// unread marker
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Chat" }).hasAttribute("data-indicator")).toBeTruthy(),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears unread marker when room notification state changes to read", async () => {
|
||||
const room = makeRoom();
|
||||
// start in unread state
|
||||
const notificationState = mockRoomNotificationState(room, NotificationLevel.Highlight);
|
||||
getComponent(room);
|
||||
|
||||
// unread marker
|
||||
expect(screen.getByRole("button", { name: "Chat" }).hasAttribute("data-indicator")).toBeTruthy();
|
||||
|
||||
// @ts-ignore ugly mocking
|
||||
notificationState._level = NotificationLevel.None;
|
||||
notificationState.emit(NotificationStateEvents.Update);
|
||||
|
||||
// unread marker cleared
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Chat" }).hasAttribute("data-indicator")).toBeFalsy(),
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,31 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<VideoRoomChatButton /> renders button with an unread marker when room is unread 1`] = `
|
||||
<button
|
||||
aria-label="Chat"
|
||||
aria-labelledby="floating-ui-6"
|
||||
class="_icon-button_bh2qc_17"
|
||||
data-indicator="default"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_133tf_26"
|
||||
data-indicator="default"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M2.95 16.3 1.5 21.25a.936.936 0 0 0 .25 1 .936.936 0 0 0 1 .25l4.95-1.45a10.23 10.23 0 0 0 2.1.712c.717.159 1.45.238 2.2.238a9.737 9.737 0 0 0 3.9-.788 10.098 10.098 0 0 0 3.175-2.137c.9-.9 1.613-1.958 2.137-3.175A9.738 9.738 0 0 0 22 12a9.738 9.738 0 0 0-.788-3.9 10.099 10.099 0 0 0-2.137-3.175c-.9-.9-1.958-1.612-3.175-2.137A9.737 9.737 0 0 0 12 2a9.737 9.737 0 0 0-3.9.788 10.099 10.099 0 0 0-3.175 2.137c-.9.9-1.612 1.958-2.137 3.175A9.738 9.738 0 0 0 2 12a10.179 10.179 0 0 0 .95 4.3Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
`;
|
320
test/unit-tests/components/views/rooms/RoomKnocksBar-test.tsx
Normal file
320
test/unit-tests/components/views/rooms/RoomKnocksBar-test.tsx
Normal file
|
@ -0,0 +1,320 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2023 Nordeck IT + Consulting GmbH
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { act, fireEvent, render, screen } from "jest-matrix-react";
|
||||
import {
|
||||
EventTimeline,
|
||||
EventType,
|
||||
JoinRule,
|
||||
MatrixError,
|
||||
MatrixEvent,
|
||||
Room,
|
||||
RoomMember,
|
||||
RoomStateEvent,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||
import React from "react";
|
||||
|
||||
import ErrorDialog from "../../../../../src/components/views/dialogs/ErrorDialog";
|
||||
import { RoomSettingsTab } from "../../../../../src/components/views/dialogs/RoomSettingsDialog";
|
||||
import { RoomKnocksBar } from "../../../../../src/components/views/rooms/RoomKnocksBar";
|
||||
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
|
||||
import dis from "../../../../../src/dispatcher/dispatcher";
|
||||
import Modal from "../../../../../src/Modal";
|
||||
import {
|
||||
clearAllModals,
|
||||
flushPromises,
|
||||
getMockClientWithEventEmitter,
|
||||
mockClientMethodsUser,
|
||||
} from "../../../../test-utils";
|
||||
import * as languageHandler from "../../../../../src/languageHandler";
|
||||
|
||||
describe("RoomKnocksBar", () => {
|
||||
const userId = "@alice:example.org";
|
||||
const client = getMockClientWithEventEmitter({
|
||||
...mockClientMethodsUser(userId),
|
||||
invite: jest.fn(),
|
||||
kick: jest.fn(),
|
||||
});
|
||||
const roomId = "#ask-to-join:example.org";
|
||||
const member = new RoomMember(roomId, userId);
|
||||
const room = new Room(roomId, client, userId);
|
||||
const state = room.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
|
||||
|
||||
type ButtonNames = "Approve" | "Deny" | "View" | "View message";
|
||||
const getButton = (name: ButtonNames) => screen.getByRole("button", { name });
|
||||
const getComponent = (room: Room) =>
|
||||
render(
|
||||
<MatrixClientContext.Provider value={client}>
|
||||
<RoomKnocksBar room={room} />
|
||||
</MatrixClientContext.Provider>,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(room, "getMember").mockReturnValue(member);
|
||||
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Knock);
|
||||
});
|
||||
|
||||
it("does not render if the room join rule is not knock", () => {
|
||||
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Invite);
|
||||
jest.spyOn(room, "getMembersWithMembership").mockReturnValue([member]);
|
||||
jest.spyOn(room, "canInvite").mockReturnValue(true);
|
||||
jest.spyOn(state, "hasSufficientPowerLevelFor").mockReturnValue(true);
|
||||
expect(getComponent(room).container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
describe("without requests to join", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(room, "getMembersWithMembership").mockReturnValue([]);
|
||||
jest.spyOn(room, "canInvite").mockReturnValue(true);
|
||||
jest.spyOn(state, "hasSufficientPowerLevelFor").mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("does not render if user can neither approve nor deny", () => {
|
||||
jest.spyOn(room, "canInvite").mockReturnValue(false);
|
||||
jest.spyOn(state, "hasSufficientPowerLevelFor").mockReturnValue(false);
|
||||
expect(getComponent(room).container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render if user cannot approve", () => {
|
||||
jest.spyOn(room, "canInvite").mockReturnValue(false);
|
||||
expect(getComponent(room).container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render if user cannot deny", () => {
|
||||
jest.spyOn(state, "hasSufficientPowerLevelFor").mockReturnValue(false);
|
||||
expect(getComponent(room).container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render if user can approve and deny", () => {
|
||||
expect(getComponent(room).container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("with requests to join", () => {
|
||||
const error = new MatrixError();
|
||||
const bob = new RoomMember(roomId, "@bob:example.org");
|
||||
const jane = new RoomMember(roomId, "@jane:example.org");
|
||||
const john = new RoomMember(roomId, "@john:example.org");
|
||||
const other = new RoomMember(roomId, "@doe:example.org");
|
||||
|
||||
bob.setMembershipEvent(
|
||||
new MatrixEvent({
|
||||
content: { displayname: "Bob", membership: KnownMembership.Knock },
|
||||
type: EventType.RoomMember,
|
||||
}),
|
||||
);
|
||||
jane.setMembershipEvent(
|
||||
new MatrixEvent({
|
||||
content: { displayname: "Jane", membership: KnownMembership.Knock },
|
||||
type: EventType.RoomMember,
|
||||
}),
|
||||
);
|
||||
john.setMembershipEvent(
|
||||
new MatrixEvent({
|
||||
content: { displayname: "John", membership: KnownMembership.Knock },
|
||||
type: EventType.RoomMember,
|
||||
}),
|
||||
);
|
||||
other.setMembershipEvent(
|
||||
new MatrixEvent({ content: { membership: KnownMembership.Knock }, type: EventType.RoomMember }),
|
||||
);
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearAllModals();
|
||||
jest.spyOn(room, "getMembersWithMembership").mockReturnValue([bob]);
|
||||
jest.spyOn(room, "canInvite").mockReturnValue(true);
|
||||
jest.spyOn(state, "hasSufficientPowerLevelFor").mockReturnValue(true);
|
||||
jest.spyOn(Modal, "createDialog");
|
||||
jest.spyOn(dis, "dispatch");
|
||||
jest.spyOn(languageHandler, "getUserLanguage").mockReturnValue("en-GB");
|
||||
});
|
||||
|
||||
it("does not render if user can neither approve nor deny", () => {
|
||||
jest.spyOn(room, "canInvite").mockReturnValue(false);
|
||||
jest.spyOn(state, "hasSufficientPowerLevelFor").mockReturnValue(false);
|
||||
expect(getComponent(room).container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("unhides the bar when a new knock request appears", () => {
|
||||
jest.spyOn(room, "getMembersWithMembership").mockReturnValue([]);
|
||||
const { container } = getComponent(room);
|
||||
expect(container.firstChild).toBeNull();
|
||||
jest.spyOn(room, "getMembersWithMembership").mockReturnValue([bob]);
|
||||
act(() => {
|
||||
room.emit(RoomStateEvent.Update, state);
|
||||
});
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
});
|
||||
|
||||
it("updates when the list of knocking users changes", () => {
|
||||
getComponent(room);
|
||||
expect(screen.getByRole("heading")).toHaveTextContent("Asking to join");
|
||||
jest.spyOn(room, "getMembersWithMembership").mockReturnValue([bob, jane]);
|
||||
act(() => {
|
||||
room.emit(RoomStateEvent.Update, state);
|
||||
});
|
||||
expect(screen.getByRole("heading")).toHaveTextContent("2 people asking to join");
|
||||
});
|
||||
|
||||
describe("when knock members count is 1", () => {
|
||||
beforeEach(() => jest.spyOn(room, "getMembersWithMembership").mockReturnValue([bob]));
|
||||
|
||||
it("renders a heading and a paragraph with name and user ID", () => {
|
||||
getComponent(room);
|
||||
expect(screen.getByRole("heading")).toHaveTextContent("Asking to join");
|
||||
expect(screen.getByRole("paragraph")).toHaveTextContent(`${bob.name} (${bob.userId})`);
|
||||
});
|
||||
|
||||
describe("when a knock reason is not provided", () => {
|
||||
it("does not render a link to open the room settings people tab", () => {
|
||||
getComponent(room);
|
||||
expect(screen.queryByRole("button", { name: "View message" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when a knock reason is provided", () => {
|
||||
it("renders a link to open the room settings people tab", () => {
|
||||
bob.setMembershipEvent(
|
||||
new MatrixEvent({
|
||||
content: { displayname: "Bob", membership: KnownMembership.Knock, reason: "some reason" },
|
||||
type: EventType.RoomMember,
|
||||
}),
|
||||
);
|
||||
getComponent(room);
|
||||
fireEvent.click(getButton("View message"));
|
||||
expect(dis.dispatch).toHaveBeenCalledWith({
|
||||
action: "open_room_settings",
|
||||
initial_tab_id: RoomSettingsTab.People,
|
||||
room_id: roomId,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
type TestCase = [string, ButtonNames, () => void];
|
||||
it.each<TestCase>([
|
||||
["deny request fails", "Deny", () => jest.spyOn(client, "kick").mockRejectedValue(error)],
|
||||
["deny request succeeds", "Deny", () => jest.spyOn(client, "kick").mockResolvedValue({})],
|
||||
["approve request fails", "Approve", () => jest.spyOn(client, "invite").mockRejectedValue(error)],
|
||||
["approve request succeeds", "Approve", () => jest.spyOn(client, "invite").mockResolvedValue({})],
|
||||
])("toggles the disabled attribute for the buttons when a %s", async (_, buttonName, setup) => {
|
||||
setup();
|
||||
getComponent(room);
|
||||
fireEvent.click(getButton(buttonName));
|
||||
expect(getButton("Deny")).toHaveAttribute("disabled");
|
||||
expect(getButton("Approve")).toHaveAttribute("disabled");
|
||||
await act(() => flushPromises());
|
||||
expect(getButton("Deny")).not.toHaveAttribute("disabled");
|
||||
expect(getButton("Approve")).not.toHaveAttribute("disabled");
|
||||
});
|
||||
|
||||
it("disables the deny button if the power level is insufficient", () => {
|
||||
jest.spyOn(state, "hasSufficientPowerLevelFor").mockReturnValue(false);
|
||||
getComponent(room);
|
||||
expect(getButton("Deny")).toHaveAttribute("disabled");
|
||||
});
|
||||
|
||||
it("calls kick on deny", async () => {
|
||||
jest.spyOn(client, "kick").mockResolvedValue({});
|
||||
getComponent(room);
|
||||
fireEvent.click(getButton("Deny"));
|
||||
await act(() => flushPromises());
|
||||
expect(client.kick).toHaveBeenCalledWith(roomId, bob.userId);
|
||||
});
|
||||
|
||||
it("displays an error when a deny request fails", async () => {
|
||||
jest.spyOn(client, "kick").mockRejectedValue(error);
|
||||
getComponent(room);
|
||||
fireEvent.click(getButton("Deny"));
|
||||
await act(() => flushPromises());
|
||||
expect(Modal.createDialog).toHaveBeenCalledWith(ErrorDialog, {
|
||||
title: error.name,
|
||||
description: error.message,
|
||||
});
|
||||
});
|
||||
|
||||
it("disables the approve button if the power level is insufficient", () => {
|
||||
jest.spyOn(room, "canInvite").mockReturnValue(false);
|
||||
getComponent(room);
|
||||
expect(getButton("Approve")).toHaveAttribute("disabled");
|
||||
});
|
||||
|
||||
it("calls invite on approve", async () => {
|
||||
jest.spyOn(client, "invite").mockResolvedValue({});
|
||||
getComponent(room);
|
||||
fireEvent.click(getButton("Approve"));
|
||||
await act(() => flushPromises());
|
||||
expect(client.invite).toHaveBeenCalledWith(roomId, bob.userId);
|
||||
});
|
||||
|
||||
it("displays an error when an approval fails", async () => {
|
||||
jest.spyOn(client, "invite").mockRejectedValue(error);
|
||||
getComponent(room);
|
||||
fireEvent.click(getButton("Approve"));
|
||||
await act(() => flushPromises());
|
||||
expect(Modal.createDialog).toHaveBeenCalledWith(ErrorDialog, {
|
||||
title: error.name,
|
||||
description: error.message,
|
||||
});
|
||||
});
|
||||
|
||||
it("hides the bar when someone else approves or denies the waiting person", () => {
|
||||
getComponent(room);
|
||||
jest.spyOn(room, "getMembersWithMembership").mockReturnValue([]);
|
||||
act(() => {
|
||||
room.emit(RoomStateEvent.Members, new MatrixEvent(), state, bob);
|
||||
});
|
||||
expect(getComponent(room).container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when knock members count is greater than 1", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(room, "getMembersWithMembership").mockReturnValue([bob, jane]);
|
||||
getComponent(room);
|
||||
});
|
||||
|
||||
it("renders a heading with count", () => {
|
||||
expect(screen.getByRole("heading")).toHaveTextContent("2 people asking to join");
|
||||
});
|
||||
|
||||
it("renders a button to open the room settings people tab", () => {
|
||||
fireEvent.click(getButton("View"));
|
||||
expect(dis.dispatch).toHaveBeenCalledWith({
|
||||
action: "open_room_settings",
|
||||
initial_tab_id: RoomSettingsTab.People,
|
||||
room_id: roomId,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when knock members count is 2", () => {
|
||||
it("renders a paragraph with two names", () => {
|
||||
jest.spyOn(room, "getMembersWithMembership").mockReturnValue([bob, jane]);
|
||||
getComponent(room);
|
||||
expect(screen.getByRole("paragraph")).toHaveTextContent(`${bob.name} and ${jane.name}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when knock members count is 3", () => {
|
||||
it("renders a paragraph with three names", () => {
|
||||
jest.spyOn(room, "getMembersWithMembership").mockReturnValue([bob, jane, john]);
|
||||
getComponent(room);
|
||||
expect(screen.getByRole("paragraph")).toHaveTextContent(`${bob.name}, ${jane.name} and ${john.name}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when knock count is greater than 3", () => {
|
||||
it("renders a paragraph with two names and a count", () => {
|
||||
jest.spyOn(room, "getMembersWithMembership").mockReturnValue([bob, jane, john, other]);
|
||||
getComponent(room);
|
||||
expect(screen.getByRole("paragraph")).toHaveTextContent(`${bob.name}, ${jane.name} and 2 others`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
274
test/unit-tests/components/views/rooms/RoomList-test.tsx
Normal file
274
test/unit-tests/components/views/rooms/RoomList-test.tsx
Normal file
|
@ -0,0 +1,274 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2023 Mikhail Aheichyk
|
||||
Copyright 2023 Nordeck IT + Consulting GmbH.
|
||||
|
||||
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 { cleanup, queryByRole, render, screen, within } from "jest-matrix-react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { mocked } from "jest-mock";
|
||||
import { Room } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import RoomList from "../../../../../src/components/views/rooms/RoomList";
|
||||
import ResizeNotifier from "../../../../../src/utils/ResizeNotifier";
|
||||
import { MetaSpace } from "../../../../../src/stores/spaces";
|
||||
import { shouldShowComponent } from "../../../../../src/customisations/helpers/UIComponents";
|
||||
import { UIComponent } from "../../../../../src/settings/UIFeature";
|
||||
import dis from "../../../../../src/dispatcher/dispatcher";
|
||||
import { Action } from "../../../../../src/dispatcher/actions";
|
||||
import * as testUtils from "../../../../test-utils";
|
||||
import { mkSpace, stubClient } from "../../../../test-utils";
|
||||
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
|
||||
import SpaceStore from "../../../../../src/stores/spaces/SpaceStore";
|
||||
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
|
||||
import RoomListStore from "../../../../../src/stores/room-list/RoomListStore";
|
||||
import { ITagMap } from "../../../../../src/stores/room-list/algorithms/models";
|
||||
import { DefaultTagID } from "../../../../../src/stores/room-list/models";
|
||||
|
||||
jest.mock("../../../../../src/customisations/helpers/UIComponents", () => ({
|
||||
shouldShowComponent: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../../../../../src/dispatcher/dispatcher");
|
||||
|
||||
const getUserIdForRoomId = jest.fn();
|
||||
const getDMRoomsForUserId = jest.fn();
|
||||
// @ts-ignore
|
||||
DMRoomMap.sharedInstance = { getUserIdForRoomId, getDMRoomsForUserId };
|
||||
|
||||
describe("RoomList", () => {
|
||||
stubClient();
|
||||
const client = MatrixClientPeg.safeGet();
|
||||
const store = SpaceStore.instance;
|
||||
|
||||
function getComponent(props: Partial<RoomList["props"]> = {}): JSX.Element {
|
||||
return (
|
||||
<RoomList
|
||||
onKeyDown={jest.fn()}
|
||||
onFocus={jest.fn()}
|
||||
onBlur={jest.fn()}
|
||||
onResize={jest.fn()}
|
||||
resizeNotifier={new ResizeNotifier()}
|
||||
isMinimized={false}
|
||||
activeSpace={MetaSpace.Home}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("Rooms", () => {
|
||||
describe("when meta space is active", () => {
|
||||
beforeEach(() => {
|
||||
store.setActiveSpace(MetaSpace.Home);
|
||||
});
|
||||
|
||||
it("does not render add room button when UIComponent customisation disables CreateRooms and ExploreRooms", () => {
|
||||
const disabled: UIComponent[] = [UIComponent.CreateRooms, UIComponent.ExploreRooms];
|
||||
mocked(shouldShowComponent).mockImplementation((feature) => !disabled.includes(feature));
|
||||
render(getComponent());
|
||||
|
||||
const roomsList = screen.getByRole("group", { name: "Rooms" });
|
||||
expect(within(roomsList).queryByRole("button", { name: "Add room" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders add room button with menu when UIComponent customisation allows CreateRooms or ExploreRooms", async () => {
|
||||
let disabled: UIComponent[] = [];
|
||||
mocked(shouldShowComponent).mockImplementation((feature) => !disabled.includes(feature));
|
||||
const { rerender } = render(getComponent());
|
||||
|
||||
const roomsList = screen.getByRole("group", { name: "Rooms" });
|
||||
const addRoomButton = within(roomsList).getByRole("button", { name: "Add room" });
|
||||
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(addRoomButton);
|
||||
|
||||
const menu = screen.getByRole("menu");
|
||||
|
||||
expect(within(menu).getByRole("menuitem", { name: "New room" })).toBeInTheDocument();
|
||||
expect(within(menu).getByRole("menuitem", { name: "Explore public rooms" })).toBeInTheDocument();
|
||||
|
||||
disabled = [UIComponent.CreateRooms];
|
||||
rerender(getComponent());
|
||||
|
||||
expect(addRoomButton).toBeInTheDocument();
|
||||
expect(menu).toBeInTheDocument();
|
||||
expect(within(menu).queryByRole("menuitem", { name: "New room" })).not.toBeInTheDocument();
|
||||
expect(within(menu).getByRole("menuitem", { name: "Explore public rooms" })).toBeInTheDocument();
|
||||
|
||||
disabled = [UIComponent.ExploreRooms];
|
||||
rerender(getComponent());
|
||||
|
||||
expect(addRoomButton).toBeInTheDocument();
|
||||
expect(menu).toBeInTheDocument();
|
||||
expect(within(menu).getByRole("menuitem", { name: "New room" })).toBeInTheDocument();
|
||||
expect(within(menu).queryByRole("menuitem", { name: "Explore public rooms" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders add room button and clicks explore public rooms", async () => {
|
||||
mocked(shouldShowComponent).mockReturnValue(true);
|
||||
render(getComponent());
|
||||
|
||||
const roomsList = screen.getByRole("group", { name: "Rooms" });
|
||||
await userEvent.click(within(roomsList).getByRole("button", { name: "Add room" }));
|
||||
|
||||
const menu = screen.getByRole("menu");
|
||||
await userEvent.click(within(menu).getByRole("menuitem", { name: "Explore public rooms" }));
|
||||
|
||||
expect(dis.fire).toHaveBeenCalledWith(Action.ViewRoomDirectory);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when room space is active", () => {
|
||||
let rooms: Room[];
|
||||
const mkSpaceForRooms = (spaceId: string, children: string[] = []) =>
|
||||
mkSpace(client, spaceId, rooms, children);
|
||||
|
||||
const space1 = "!space1:server";
|
||||
|
||||
beforeEach(async () => {
|
||||
rooms = [];
|
||||
mkSpaceForRooms(space1);
|
||||
mocked(client).getRoom.mockImplementation(
|
||||
(roomId) => rooms.find((room) => room.roomId === roomId) || null,
|
||||
);
|
||||
await testUtils.setupAsyncStoreWithClient(store, client);
|
||||
|
||||
store.setActiveSpace(space1);
|
||||
});
|
||||
|
||||
it("does not render add room button when UIComponent customisation disables CreateRooms and ExploreRooms", () => {
|
||||
const disabled: UIComponent[] = [UIComponent.CreateRooms, UIComponent.ExploreRooms];
|
||||
mocked(shouldShowComponent).mockImplementation((feature) => !disabled.includes(feature));
|
||||
render(getComponent());
|
||||
|
||||
const roomsList = screen.getByRole("group", { name: "Rooms" });
|
||||
expect(within(roomsList).queryByRole("button", { name: "Add room" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders add room button with menu when UIComponent customisation allows CreateRooms or ExploreRooms", async () => {
|
||||
let disabled: UIComponent[] = [];
|
||||
mocked(shouldShowComponent).mockImplementation((feature) => !disabled.includes(feature));
|
||||
const { rerender } = render(getComponent());
|
||||
|
||||
const roomsList = screen.getByRole("group", { name: "Rooms" });
|
||||
const addRoomButton = within(roomsList).getByRole("button", { name: "Add room" });
|
||||
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(addRoomButton);
|
||||
|
||||
const menu = screen.getByRole("menu");
|
||||
|
||||
expect(within(menu).getByRole("menuitem", { name: "Explore rooms" })).toBeInTheDocument();
|
||||
expect(within(menu).getByRole("menuitem", { name: "New room" })).toBeInTheDocument();
|
||||
expect(within(menu).getByRole("menuitem", { name: "Add existing room" })).toBeInTheDocument();
|
||||
|
||||
disabled = [UIComponent.CreateRooms];
|
||||
rerender(getComponent());
|
||||
|
||||
expect(addRoomButton).toBeInTheDocument();
|
||||
expect(menu).toBeInTheDocument();
|
||||
expect(within(menu).getByRole("menuitem", { name: "Explore rooms" })).toBeInTheDocument();
|
||||
expect(within(menu).queryByRole("menuitem", { name: "New room" })).not.toBeInTheDocument();
|
||||
expect(within(menu).queryByRole("menuitem", { name: "Add existing room" })).not.toBeInTheDocument();
|
||||
|
||||
disabled = [UIComponent.ExploreRooms];
|
||||
rerender(getComponent());
|
||||
|
||||
expect(addRoomButton).toBeInTheDocument();
|
||||
expect(menu).toBeInTheDocument();
|
||||
expect(within(menu).queryByRole("menuitem", { name: "Explore rooms" })).toBeInTheDocument();
|
||||
expect(within(menu).getByRole("menuitem", { name: "New room" })).toBeInTheDocument();
|
||||
expect(within(menu).getByRole("menuitem", { name: "Add existing room" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders add room button and clicks explore rooms", async () => {
|
||||
mocked(shouldShowComponent).mockReturnValue(true);
|
||||
render(getComponent());
|
||||
|
||||
const roomsList = screen.getByRole("group", { name: "Rooms" });
|
||||
await userEvent.click(within(roomsList).getByRole("button", { name: "Add room" }));
|
||||
|
||||
const menu = screen.getByRole("menu");
|
||||
await userEvent.click(within(menu).getByRole("menuitem", { name: "Explore rooms" }));
|
||||
|
||||
expect(dis.dispatch).toHaveBeenCalledWith({
|
||||
action: Action.ViewRoom,
|
||||
room_id: space1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when video meta space is active", () => {
|
||||
const videoRoomPrivate = "!videoRoomPrivate_server";
|
||||
const videoRoomPublic = "!videoRoomPublic_server";
|
||||
const videoRoomKnock = "!videoRoomKnock_server";
|
||||
|
||||
beforeEach(async () => {
|
||||
cleanup();
|
||||
const rooms: Room[] = [];
|
||||
RoomListStore.instance;
|
||||
testUtils.mkRoom(client, videoRoomPrivate, rooms);
|
||||
testUtils.mkRoom(client, videoRoomPublic, rooms);
|
||||
testUtils.mkRoom(client, videoRoomKnock, rooms);
|
||||
|
||||
mocked(client).getRoom.mockImplementation(
|
||||
(roomId) => rooms.find((room) => room.roomId === roomId) || null,
|
||||
);
|
||||
mocked(client).getRooms.mockImplementation(() => rooms);
|
||||
|
||||
const videoRoomKnockRoom = client.getRoom(videoRoomKnock)!;
|
||||
const videoRoomPrivateRoom = client.getRoom(videoRoomPrivate)!;
|
||||
const videoRoomPublicRoom = client.getRoom(videoRoomPublic)!;
|
||||
|
||||
[videoRoomPrivateRoom, videoRoomPublicRoom, videoRoomKnockRoom].forEach((room) => {
|
||||
(room.isCallRoom as jest.Mock).mockReturnValue(true);
|
||||
});
|
||||
|
||||
const roomLists: ITagMap = {};
|
||||
roomLists[DefaultTagID.Conference] = [videoRoomKnockRoom, videoRoomPublicRoom];
|
||||
roomLists[DefaultTagID.Untagged] = [videoRoomPrivateRoom];
|
||||
jest.spyOn(RoomListStore.instance, "orderedLists", "get").mockReturnValue(roomLists);
|
||||
await testUtils.setupAsyncStoreWithClient(store, client);
|
||||
|
||||
store.setActiveSpace(MetaSpace.VideoRooms);
|
||||
});
|
||||
|
||||
it("renders Conferences and Room but no People section", () => {
|
||||
const renderResult = render(getComponent({ activeSpace: MetaSpace.VideoRooms }));
|
||||
const roomsEl = renderResult.getByRole("treeitem", { name: "Rooms" });
|
||||
const conferenceEl = renderResult.getByRole("treeitem", { name: "Conferences" });
|
||||
|
||||
const noInvites = screen.queryByRole("treeitem", { name: "Invites" });
|
||||
const noFavourites = screen.queryByRole("treeitem", { name: "Favourites" });
|
||||
const noPeople = screen.queryByRole("treeitem", { name: "People" });
|
||||
const noLowPriority = screen.queryByRole("treeitem", { name: "Low priority" });
|
||||
const noHistorical = screen.queryByRole("treeitem", { name: "Historical" });
|
||||
|
||||
expect(roomsEl).toBeVisible();
|
||||
expect(conferenceEl).toBeVisible();
|
||||
|
||||
expect(noInvites).toBeFalsy();
|
||||
expect(noFavourites).toBeFalsy();
|
||||
expect(noPeople).toBeFalsy();
|
||||
expect(noLowPriority).toBeFalsy();
|
||||
expect(noHistorical).toBeFalsy();
|
||||
});
|
||||
it("renders Public and Knock rooms in Conferences section", () => {
|
||||
const renderResult = render(getComponent({ activeSpace: MetaSpace.VideoRooms }));
|
||||
const conferenceList = renderResult.getByRole("group", { name: "Conferences" });
|
||||
expect(queryByRole(conferenceList, "treeitem", { name: videoRoomPublic })).toBeVisible();
|
||||
expect(queryByRole(conferenceList, "treeitem", { name: videoRoomKnock })).toBeVisible();
|
||||
expect(queryByRole(conferenceList, "treeitem", { name: videoRoomPrivate })).toBeFalsy();
|
||||
|
||||
const roomsList = renderResult.getByRole("group", { name: "Rooms" });
|
||||
expect(queryByRole(roomsList, "treeitem", { name: videoRoomPrivate })).toBeVisible();
|
||||
expect(queryByRole(roomsList, "treeitem", { name: videoRoomPublic })).toBeFalsy();
|
||||
expect(queryByRole(roomsList, "treeitem", { name: videoRoomKnock })).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
280
test/unit-tests/components/views/rooms/RoomListHeader-test.tsx
Normal file
280
test/unit-tests/components/views/rooms/RoomListHeader-test.tsx
Normal file
|
@ -0,0 +1,280 @@
|
|||
/*
|
||||
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 React from "react";
|
||||
import { MatrixClient, Room, EventType } from "matrix-js-sdk/src/matrix";
|
||||
import { mocked } from "jest-mock";
|
||||
import { act, render, screen, fireEvent, RenderResult } from "jest-matrix-react";
|
||||
|
||||
import SpaceStore from "../../../../../src/stores/spaces/SpaceStore";
|
||||
import { MetaSpace } from "../../../../../src/stores/spaces";
|
||||
import _RoomListHeader from "../../../../../src/components/views/rooms/RoomListHeader";
|
||||
import * as testUtils from "../../../../test-utils";
|
||||
import { stubClient, mkSpace } from "../../../../test-utils";
|
||||
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
|
||||
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
|
||||
import SettingsStore from "../../../../../src/settings/SettingsStore";
|
||||
import { SettingLevel } from "../../../../../src/settings/SettingLevel";
|
||||
import { shouldShowComponent } from "../../../../../src/customisations/helpers/UIComponents";
|
||||
import { UIComponent } from "../../../../../src/settings/UIFeature";
|
||||
|
||||
const RoomListHeader = testUtils.wrapInMatrixClientContext(_RoomListHeader);
|
||||
|
||||
jest.mock("../../../../../src/customisations/helpers/UIComponents", () => ({
|
||||
shouldShowComponent: jest.fn(),
|
||||
}));
|
||||
|
||||
const blockUIComponent = (component: UIComponent): void => {
|
||||
mocked(shouldShowComponent).mockImplementation((feature) => feature !== component);
|
||||
};
|
||||
|
||||
const setupSpace = (client: MatrixClient): Room => {
|
||||
const testSpace: Room = mkSpace(client, "!space:server");
|
||||
testSpace.name = "Test Space";
|
||||
client.getRoom = () => testSpace;
|
||||
return testSpace;
|
||||
};
|
||||
|
||||
const setupMainMenu = async (client: MatrixClient, testSpace: Room): Promise<RenderResult> => {
|
||||
await testUtils.setupAsyncStoreWithClient(SpaceStore.instance, client);
|
||||
act(() => {
|
||||
SpaceStore.instance.setActiveSpace(testSpace.roomId);
|
||||
});
|
||||
|
||||
const wrapper = render(<RoomListHeader />);
|
||||
|
||||
expect(wrapper.container.textContent).toBe("Test Space");
|
||||
act(() => {
|
||||
wrapper.container.querySelector<HTMLElement>('[aria-label="Test Space menu"]')?.click();
|
||||
});
|
||||
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
const setupPlusMenu = async (client: MatrixClient, testSpace: Room): Promise<RenderResult> => {
|
||||
await testUtils.setupAsyncStoreWithClient(SpaceStore.instance, client);
|
||||
act(() => {
|
||||
SpaceStore.instance.setActiveSpace(testSpace.roomId);
|
||||
});
|
||||
|
||||
const wrapper = render(<RoomListHeader />);
|
||||
|
||||
expect(wrapper.container.textContent).toBe("Test Space");
|
||||
act(() => {
|
||||
wrapper.container.querySelector<HTMLElement>('[aria-label="Add"]')?.click();
|
||||
});
|
||||
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
const checkIsDisabled = (menuItem: HTMLElement): void => {
|
||||
expect(menuItem).toHaveAttribute("disabled");
|
||||
expect(menuItem).toHaveAttribute("aria-disabled", "true");
|
||||
};
|
||||
|
||||
const checkMenuLabels = (items: NodeListOf<Element>, labelArray: Array<string>) => {
|
||||
expect(items).toHaveLength(labelArray.length);
|
||||
|
||||
const checkLabel = (item: Element, label: string) => {
|
||||
expect(item.querySelector(".mx_IconizedContextMenu_label")?.textContent).toBe(label);
|
||||
};
|
||||
|
||||
labelArray.forEach((label, index) => {
|
||||
console.log("index", index, "label", label);
|
||||
checkLabel(items[index], label);
|
||||
});
|
||||
};
|
||||
|
||||
describe("RoomListHeader", () => {
|
||||
let client: MatrixClient;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
|
||||
const dmRoomMap = {
|
||||
getUserIdForRoomId: jest.fn(),
|
||||
getDMRoomsForUserId: jest.fn(),
|
||||
} as unknown as DMRoomMap;
|
||||
DMRoomMap.setShared(dmRoomMap);
|
||||
stubClient();
|
||||
client = MatrixClientPeg.safeGet();
|
||||
mocked(shouldShowComponent).mockReturnValue(true); // show all UIComponents
|
||||
});
|
||||
|
||||
it("renders a main menu for the home space", () => {
|
||||
act(() => {
|
||||
SpaceStore.instance.setActiveSpace(MetaSpace.Home);
|
||||
});
|
||||
|
||||
const { container } = render(<RoomListHeader />);
|
||||
|
||||
expect(container.textContent).toBe("Home");
|
||||
fireEvent.click(screen.getByLabelText("Home options"));
|
||||
|
||||
const menu = screen.getByRole("menu");
|
||||
const items = menu.querySelectorAll(".mx_IconizedContextMenu_item");
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].textContent).toBe("Show all rooms");
|
||||
});
|
||||
|
||||
it("renders a main menu for spaces", async () => {
|
||||
const testSpace = setupSpace(client);
|
||||
await setupMainMenu(client, testSpace);
|
||||
|
||||
const menu = screen.getByRole("menu");
|
||||
const items = menu.querySelectorAll(".mx_IconizedContextMenu_item");
|
||||
|
||||
checkMenuLabels(items, ["Space home", "Manage & explore rooms", "Preferences", "Settings", "Room", "Space"]);
|
||||
});
|
||||
|
||||
it("renders a plus menu for spaces", async () => {
|
||||
const testSpace = setupSpace(client);
|
||||
await setupPlusMenu(client, testSpace);
|
||||
|
||||
const menu = screen.getByRole("menu");
|
||||
const items = menu.querySelectorAll(".mx_IconizedContextMenu_item");
|
||||
|
||||
checkMenuLabels(items, ["New room", "Explore rooms", "Add existing room", "Add space"]);
|
||||
});
|
||||
|
||||
it("closes menu if space changes from under it", async () => {
|
||||
await SettingsStore.setValue("Spaces.enabledMetaSpaces", null, SettingLevel.DEVICE, {
|
||||
[MetaSpace.Home]: true,
|
||||
[MetaSpace.Favourites]: true,
|
||||
});
|
||||
|
||||
const testSpace = setupSpace(client);
|
||||
await setupMainMenu(client, testSpace);
|
||||
|
||||
act(() => {
|
||||
SpaceStore.instance.setActiveSpace(MetaSpace.Favourites);
|
||||
});
|
||||
|
||||
screen.getByText("Favourites");
|
||||
expect(screen.queryByRole("menu")).toBeFalsy();
|
||||
});
|
||||
|
||||
describe("UIComponents", () => {
|
||||
describe("Main menu", () => {
|
||||
it("does not render Add Space when user does not have permission to add spaces", async () => {
|
||||
// User does not have permission to add spaces, anywhere
|
||||
blockUIComponent(UIComponent.CreateSpaces);
|
||||
|
||||
const testSpace = setupSpace(client);
|
||||
await setupMainMenu(client, testSpace);
|
||||
|
||||
const menu = screen.getByRole("menu");
|
||||
const items = menu.querySelectorAll(".mx_IconizedContextMenu_item");
|
||||
checkMenuLabels(items, [
|
||||
"Space home",
|
||||
"Manage & explore rooms",
|
||||
"Preferences",
|
||||
"Settings",
|
||||
"Room",
|
||||
// no add space
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not render Add Room when user does not have permission to add rooms", async () => {
|
||||
// User does not have permission to add rooms
|
||||
blockUIComponent(UIComponent.CreateRooms);
|
||||
|
||||
const testSpace = setupSpace(client);
|
||||
await setupMainMenu(client, testSpace);
|
||||
|
||||
const menu = screen.getByRole("menu");
|
||||
const items = menu.querySelectorAll(".mx_IconizedContextMenu_item");
|
||||
checkMenuLabels(items, [
|
||||
"Space home",
|
||||
"Explore rooms", // not Manage & explore rooms
|
||||
"Preferences",
|
||||
"Settings",
|
||||
// no add room
|
||||
"Space",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Plus menu", () => {
|
||||
it("does not render Add Space when user does not have permission to add spaces", async () => {
|
||||
// User does not have permission to add spaces, anywhere
|
||||
blockUIComponent(UIComponent.CreateSpaces);
|
||||
|
||||
const testSpace = setupSpace(client);
|
||||
await setupPlusMenu(client, testSpace);
|
||||
|
||||
const menu = screen.getByRole("menu");
|
||||
const items = menu.querySelectorAll(".mx_IconizedContextMenu_item");
|
||||
|
||||
checkMenuLabels(items, [
|
||||
"New room",
|
||||
"Explore rooms",
|
||||
"Add existing room",
|
||||
// no Add space
|
||||
]);
|
||||
});
|
||||
|
||||
it("disables Add Room when user does not have permission to add rooms", async () => {
|
||||
// User does not have permission to add rooms
|
||||
blockUIComponent(UIComponent.CreateRooms);
|
||||
|
||||
const testSpace = setupSpace(client);
|
||||
await setupPlusMenu(client, testSpace);
|
||||
|
||||
const menu = screen.getByRole("menu");
|
||||
const items = menu.querySelectorAll<HTMLElement>(".mx_IconizedContextMenu_item");
|
||||
|
||||
checkMenuLabels(items, ["New room", "Explore rooms", "Add existing room", "Add space"]);
|
||||
|
||||
// "Add existing room" is disabled
|
||||
checkIsDisabled(items[2]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("adding children to space", () => {
|
||||
it("if user cannot add children to space, MainMenu adding buttons are hidden", async () => {
|
||||
const testSpace = setupSpace(client);
|
||||
mocked(testSpace.currentState.maySendStateEvent).mockImplementation(
|
||||
(stateEventType, userId) => stateEventType !== EventType.SpaceChild,
|
||||
);
|
||||
|
||||
await setupMainMenu(client, testSpace);
|
||||
|
||||
const menu = screen.getByRole("menu");
|
||||
const items = menu.querySelectorAll(".mx_IconizedContextMenu_item");
|
||||
checkMenuLabels(items, [
|
||||
"Space home",
|
||||
"Explore rooms", // not Manage & explore rooms
|
||||
"Preferences",
|
||||
"Settings",
|
||||
// no add room
|
||||
// no add space
|
||||
]);
|
||||
});
|
||||
|
||||
it("if user cannot add children to space, PlusMenu add buttons are disabled", async () => {
|
||||
const testSpace = setupSpace(client);
|
||||
mocked(testSpace.currentState.maySendStateEvent).mockImplementation(
|
||||
(stateEventType, userId) => stateEventType !== EventType.SpaceChild,
|
||||
);
|
||||
|
||||
await setupPlusMenu(client, testSpace);
|
||||
|
||||
const menu = screen.getByRole("menu");
|
||||
const items = menu.querySelectorAll<HTMLElement>(".mx_IconizedContextMenu_item");
|
||||
|
||||
checkMenuLabels(items, ["New room", "Explore rooms", "Add existing room", "Add space"]);
|
||||
|
||||
// "Add existing room" is disabled
|
||||
checkIsDisabled(items[2]);
|
||||
// "Add space" is disabled
|
||||
checkIsDisabled(items[3]);
|
||||
});
|
||||
});
|
||||
});
|
518
test/unit-tests/components/views/rooms/RoomPreviewBar-test.tsx
Normal file
518
test/unit-tests/components/views/rooms/RoomPreviewBar-test.tsx
Normal file
|
@ -0,0 +1,518 @@
|
|||
/*
|
||||
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, { ComponentProps } from "react";
|
||||
import { render, fireEvent, RenderResult, waitFor, waitForElementToBeRemoved } from "jest-matrix-react";
|
||||
import { Room, RoomMember, MatrixError, IContent } from "matrix-js-sdk/src/matrix";
|
||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||
|
||||
import { withClientContextRenderOptions, stubClient } from "../../../../test-utils";
|
||||
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
|
||||
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
|
||||
import RoomPreviewBar from "../../../../../src/components/views/rooms/RoomPreviewBar";
|
||||
import defaultDispatcher from "../../../../../src/dispatcher/dispatcher";
|
||||
|
||||
jest.mock("../../../../../src/IdentityAuthClient", () => {
|
||||
return jest.fn().mockImplementation(() => {
|
||||
return { getAccessToken: jest.fn().mockResolvedValue("mock-token") };
|
||||
});
|
||||
});
|
||||
|
||||
jest.useRealTimers();
|
||||
|
||||
const createRoom = (roomId: string, userId: string): Room => {
|
||||
const cli = MatrixClientPeg.safeGet();
|
||||
const newRoom = new Room(roomId, cli, userId, {});
|
||||
DMRoomMap.makeShared(cli).start();
|
||||
return newRoom;
|
||||
};
|
||||
|
||||
const makeMockRoomMember = ({
|
||||
userId,
|
||||
isKicked,
|
||||
membership,
|
||||
content,
|
||||
memberContent,
|
||||
oldMembership,
|
||||
}: {
|
||||
userId?: string;
|
||||
isKicked?: boolean;
|
||||
membership?: KnownMembership.Invite | KnownMembership.Ban | KnownMembership.Leave;
|
||||
content?: Partial<IContent>;
|
||||
memberContent?: Partial<IContent>;
|
||||
oldMembership?: KnownMembership.Join | KnownMembership.Knock;
|
||||
}) =>
|
||||
({
|
||||
userId,
|
||||
rawDisplayName: `${userId} name`,
|
||||
isKicked: jest.fn().mockReturnValue(!!isKicked),
|
||||
getContent: jest.fn().mockReturnValue(content || {}),
|
||||
getPrevContent: jest.fn().mockReturnValue(content || {}),
|
||||
membership,
|
||||
events: {
|
||||
member: {
|
||||
getSender: jest.fn().mockReturnValue("@kicker:test.com"),
|
||||
getContent: jest.fn().mockReturnValue({ reason: "test reason", ...memberContent }),
|
||||
getPrevContent: jest.fn().mockReturnValue({ membership: oldMembership, ...memberContent }),
|
||||
},
|
||||
},
|
||||
}) as unknown as RoomMember;
|
||||
|
||||
describe("<RoomPreviewBar />", () => {
|
||||
const roomId = "RoomPreviewBar-test-room";
|
||||
const userId = "@tester:test.com";
|
||||
const inviterUserId = "@inviter:test.com";
|
||||
const otherUserId = "@othertester:test.com";
|
||||
|
||||
const getComponent = (props: ComponentProps<typeof RoomPreviewBar> = {}) => {
|
||||
const defaultProps = {
|
||||
roomId,
|
||||
room: createRoom(roomId, userId),
|
||||
};
|
||||
return render(
|
||||
<RoomPreviewBar {...defaultProps} {...props} />,
|
||||
withClientContextRenderOptions(MatrixClientPeg.safeGet()),
|
||||
);
|
||||
};
|
||||
|
||||
const isSpinnerRendered = (wrapper: RenderResult) => !!wrapper.container.querySelector(".mx_Spinner");
|
||||
const getMessage = (wrapper: RenderResult) =>
|
||||
wrapper.container.querySelector<HTMLDivElement>(".mx_RoomPreviewBar_message");
|
||||
const getActions = (wrapper: RenderResult) =>
|
||||
wrapper.container.querySelector<HTMLDivElement>(".mx_RoomPreviewBar_actions");
|
||||
const getPrimaryActionButton = (wrapper: RenderResult) =>
|
||||
getActions(wrapper)?.querySelector(".mx_AccessibleButton_kind_primary");
|
||||
const getSecondaryActionButton = (wrapper: RenderResult) =>
|
||||
getActions(wrapper)?.querySelector(".mx_AccessibleButton_kind_secondary");
|
||||
|
||||
beforeEach(() => {
|
||||
stubClient();
|
||||
MatrixClientPeg.get()!.getUserId = jest.fn().mockReturnValue(userId);
|
||||
MatrixClientPeg.get()!.getSafeUserId = jest.fn().mockReturnValue(userId);
|
||||
MatrixClientPeg.safeGet().getUserId = jest.fn().mockReturnValue(userId);
|
||||
MatrixClientPeg.safeGet().getSafeUserId = jest.fn().mockReturnValue(userId);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
const container = document.body.firstChild;
|
||||
container && document.body.removeChild(container);
|
||||
});
|
||||
|
||||
it("renders joining message", () => {
|
||||
const component = getComponent({ joining: true });
|
||||
|
||||
expect(isSpinnerRendered(component)).toBeTruthy();
|
||||
expect(getMessage(component)?.textContent).toEqual("Joining…");
|
||||
});
|
||||
it("renders rejecting message", () => {
|
||||
const component = getComponent({ rejecting: true });
|
||||
expect(isSpinnerRendered(component)).toBeTruthy();
|
||||
expect(getMessage(component)?.textContent).toEqual("Rejecting invite…");
|
||||
});
|
||||
it("renders loading message", () => {
|
||||
const component = getComponent({ loading: true });
|
||||
expect(isSpinnerRendered(component)).toBeTruthy();
|
||||
expect(getMessage(component)?.textContent).toEqual("Loading…");
|
||||
});
|
||||
|
||||
it("renders not logged in message", () => {
|
||||
MatrixClientPeg.safeGet().isGuest = jest.fn().mockReturnValue(true);
|
||||
const component = getComponent({ loading: true });
|
||||
|
||||
expect(isSpinnerRendered(component)).toBeFalsy();
|
||||
expect(getMessage(component)?.textContent).toEqual("Join the conversation with an account");
|
||||
});
|
||||
|
||||
it("should send room oob data to start login", async () => {
|
||||
MatrixClientPeg.safeGet().isGuest = jest.fn().mockReturnValue(true);
|
||||
const component = getComponent({
|
||||
oobData: {
|
||||
name: "Room Name",
|
||||
avatarUrl: "mxc://foo/bar",
|
||||
inviterName: "Charlie",
|
||||
},
|
||||
});
|
||||
|
||||
const dispatcherSpy = jest.fn();
|
||||
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
|
||||
|
||||
expect(getMessage(component)?.textContent).toEqual("Join the conversation with an account");
|
||||
fireEvent.click(getPrimaryActionButton(component)!);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(dispatcherSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
screenAfterLogin: {
|
||||
screen: "room",
|
||||
params: expect.objectContaining({
|
||||
room_name: "Room Name",
|
||||
room_avatar_url: "mxc://foo/bar",
|
||||
inviter_name: "Charlie",
|
||||
}),
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
defaultDispatcher.unregister(dispatcherRef);
|
||||
});
|
||||
|
||||
it("renders kicked message", () => {
|
||||
const room = createRoom(roomId, otherUserId);
|
||||
jest.spyOn(room, "getMember").mockReturnValue(makeMockRoomMember({ isKicked: true }));
|
||||
const component = getComponent({ room, canAskToJoinAndMembershipIsLeave: true, promptAskToJoin: false });
|
||||
|
||||
expect(getMessage(component)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders denied request message", () => {
|
||||
const room = createRoom(roomId, otherUserId);
|
||||
jest.spyOn(room, "getMember").mockReturnValue(
|
||||
makeMockRoomMember({
|
||||
isKicked: true,
|
||||
membership: KnownMembership.Leave,
|
||||
oldMembership: KnownMembership.Knock,
|
||||
}),
|
||||
);
|
||||
const component = getComponent({ room, promptAskToJoin: true });
|
||||
|
||||
expect(getMessage(component)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("triggers the primary action callback for denied request", () => {
|
||||
const onForgetClick = jest.fn();
|
||||
const room = createRoom(roomId, otherUserId);
|
||||
jest.spyOn(room, "getMember").mockReturnValue(
|
||||
makeMockRoomMember({
|
||||
isKicked: true,
|
||||
membership: KnownMembership.Leave,
|
||||
oldMembership: KnownMembership.Knock,
|
||||
}),
|
||||
);
|
||||
const component = getComponent({ room, promptAskToJoin: true, onForgetClick });
|
||||
|
||||
fireEvent.click(getPrimaryActionButton(component)!);
|
||||
expect(onForgetClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders banned message", () => {
|
||||
const room = createRoom(roomId, otherUserId);
|
||||
jest.spyOn(room, "getMember").mockReturnValue(makeMockRoomMember({ membership: KnownMembership.Ban }));
|
||||
const component = getComponent({ loading: true, room });
|
||||
|
||||
expect(getMessage(component)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe("with an error", () => {
|
||||
it("renders room not found error", () => {
|
||||
const error = new MatrixError({
|
||||
errcode: "M_NOT_FOUND",
|
||||
error: "Room not found",
|
||||
});
|
||||
const component = getComponent({ error });
|
||||
|
||||
expect(getMessage(component)).toMatchSnapshot();
|
||||
});
|
||||
it("renders other errors", () => {
|
||||
const error = new MatrixError({
|
||||
errcode: "Something_else",
|
||||
});
|
||||
const component = getComponent({ error });
|
||||
|
||||
expect(getMessage(component)).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders viewing room message when room an be previewed", () => {
|
||||
const component = getComponent({ canPreview: true });
|
||||
|
||||
expect(getMessage(component)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders viewing room message when room can not be previewed", () => {
|
||||
const component = getComponent({ canPreview: false });
|
||||
|
||||
expect(getMessage(component)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe("with an invite", () => {
|
||||
const inviterName = inviterUserId;
|
||||
const userMember = makeMockRoomMember({ userId });
|
||||
const userMemberWithDmInvite = makeMockRoomMember({
|
||||
userId,
|
||||
membership: KnownMembership.Invite,
|
||||
memberContent: { is_direct: true, membership: KnownMembership.Invite },
|
||||
});
|
||||
const inviterMember = makeMockRoomMember({
|
||||
userId: inviterUserId,
|
||||
content: {
|
||||
"reason": "test",
|
||||
"io.element.html_reason": "<h3>hello</h3>",
|
||||
},
|
||||
});
|
||||
describe("without an invited email", () => {
|
||||
describe("for a non-dm room", () => {
|
||||
const mockGetMember = (id: string) => {
|
||||
if (id === userId) return userMember;
|
||||
return inviterMember;
|
||||
};
|
||||
const onJoinClick = jest.fn();
|
||||
const onRejectClick = jest.fn();
|
||||
let room: Room;
|
||||
|
||||
beforeEach(() => {
|
||||
room = createRoom(roomId, userId);
|
||||
jest.spyOn(room, "getMember").mockImplementation(mockGetMember);
|
||||
jest.spyOn(room.currentState, "getMember").mockImplementation(mockGetMember);
|
||||
onJoinClick.mockClear();
|
||||
onRejectClick.mockClear();
|
||||
});
|
||||
|
||||
it("renders invite message", () => {
|
||||
const component = getComponent({ inviterName, room });
|
||||
expect(getMessage(component)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders join and reject action buttons correctly", () => {
|
||||
const component = getComponent({ inviterName, room, onJoinClick, onRejectClick });
|
||||
expect(getActions(component)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders reject and ignore action buttons when handler is provided", () => {
|
||||
const onRejectAndIgnoreClick = jest.fn();
|
||||
const component = getComponent({
|
||||
inviterName,
|
||||
room,
|
||||
onJoinClick,
|
||||
onRejectClick,
|
||||
onRejectAndIgnoreClick,
|
||||
});
|
||||
expect(getActions(component)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders join and reject action buttons in reverse order when room can previewed", () => {
|
||||
// when room is previewed action buttons are rendered left to right, with primary on the right
|
||||
const component = getComponent({ inviterName, room, onJoinClick, onRejectClick, canPreview: true });
|
||||
expect(getActions(component)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("joins room on primary button click", () => {
|
||||
const component = getComponent({ inviterName, room, onJoinClick, onRejectClick });
|
||||
fireEvent.click(getPrimaryActionButton(component)!);
|
||||
|
||||
expect(onJoinClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects invite on secondary button click", () => {
|
||||
const component = getComponent({ inviterName, room, onJoinClick, onRejectClick });
|
||||
fireEvent.click(getSecondaryActionButton(component)!);
|
||||
|
||||
expect(onRejectClick).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("for a dm room", () => {
|
||||
const mockGetMember = (id: string) => {
|
||||
if (id === userId) return userMemberWithDmInvite;
|
||||
return inviterMember;
|
||||
};
|
||||
const onJoinClick = jest.fn();
|
||||
const onRejectClick = jest.fn();
|
||||
let room: Room;
|
||||
|
||||
beforeEach(() => {
|
||||
room = createRoom(roomId, userId);
|
||||
jest.spyOn(room, "getMember").mockImplementation(mockGetMember);
|
||||
jest.spyOn(room.currentState, "getMember").mockImplementation(mockGetMember);
|
||||
onJoinClick.mockClear();
|
||||
onRejectClick.mockClear();
|
||||
});
|
||||
|
||||
it("renders invite message", () => {
|
||||
const component = getComponent({ inviterName, room });
|
||||
expect(getMessage(component)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders join and reject action buttons with correct labels", () => {
|
||||
const onRejectAndIgnoreClick = jest.fn();
|
||||
const component = getComponent({
|
||||
inviterName,
|
||||
room,
|
||||
onJoinClick,
|
||||
onRejectAndIgnoreClick,
|
||||
onRejectClick,
|
||||
});
|
||||
expect(getActions(component)).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("with an invited email", () => {
|
||||
const invitedEmail = "test@test.com";
|
||||
const mockThreePids = [
|
||||
{ medium: "email", address: invitedEmail },
|
||||
{ medium: "not-email", address: "address 2" },
|
||||
];
|
||||
|
||||
const testJoinButton =
|
||||
(props: ComponentProps<typeof RoomPreviewBar>, expectSecondaryButton = false) =>
|
||||
async () => {
|
||||
const onJoinClick = jest.fn();
|
||||
const onRejectClick = jest.fn();
|
||||
const component = getComponent({ ...props, onJoinClick, onRejectClick });
|
||||
await waitFor(() => expect(getPrimaryActionButton(component)).toBeTruthy());
|
||||
if (expectSecondaryButton) expect(getSecondaryActionButton(component)).toBeFalsy();
|
||||
fireEvent.click(getPrimaryActionButton(component)!);
|
||||
expect(onJoinClick).toHaveBeenCalled();
|
||||
};
|
||||
|
||||
describe("when client fails to get 3PIDs", () => {
|
||||
beforeEach(() => {
|
||||
MatrixClientPeg.safeGet().getThreePids = jest.fn().mockRejectedValue({ errCode: "TEST_ERROR" });
|
||||
});
|
||||
|
||||
it("renders error message", async () => {
|
||||
const component = getComponent({ inviterName, invitedEmail });
|
||||
await waitForElementToBeRemoved(() => component.queryByRole("progressbar"));
|
||||
|
||||
expect(getMessage(component)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders join button", testJoinButton({ inviterName, invitedEmail }));
|
||||
});
|
||||
|
||||
describe("when invitedEmail is not associated with current account", () => {
|
||||
beforeEach(() => {
|
||||
MatrixClientPeg.safeGet().getThreePids = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ threepids: mockThreePids.slice(1) });
|
||||
});
|
||||
|
||||
it("renders invite message with invited email", async () => {
|
||||
const component = getComponent({ inviterName, invitedEmail });
|
||||
await waitForElementToBeRemoved(() => component.queryByRole("progressbar"));
|
||||
|
||||
expect(getMessage(component)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders join button", testJoinButton({ inviterName, invitedEmail }));
|
||||
});
|
||||
|
||||
describe("when client has no identity server connected", () => {
|
||||
beforeEach(() => {
|
||||
MatrixClientPeg.safeGet().getThreePids = jest.fn().mockResolvedValue({ threepids: mockThreePids });
|
||||
MatrixClientPeg.safeGet().getIdentityServerUrl = jest.fn().mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("renders invite message with invited email", async () => {
|
||||
const component = getComponent({ inviterName, invitedEmail });
|
||||
await waitForElementToBeRemoved(() => component.queryByRole("progressbar"));
|
||||
|
||||
expect(getMessage(component)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders join button", testJoinButton({ inviterName, invitedEmail }));
|
||||
});
|
||||
|
||||
describe("when client has an identity server connected", () => {
|
||||
beforeEach(() => {
|
||||
MatrixClientPeg.safeGet().getThreePids = jest.fn().mockResolvedValue({ threepids: mockThreePids });
|
||||
MatrixClientPeg.safeGet().getIdentityServerUrl = jest.fn().mockReturnValue("identity.test");
|
||||
MatrixClientPeg.safeGet().lookupThreePid = jest.fn().mockResolvedValue("identity.test");
|
||||
});
|
||||
|
||||
it("renders email mismatch message when invite email mxid doesnt match", async () => {
|
||||
MatrixClientPeg.safeGet().lookupThreePid = jest.fn().mockReturnValue({ mxid: "not userid" });
|
||||
const component = getComponent({ inviterName, invitedEmail });
|
||||
await waitForElementToBeRemoved(() => component.queryByRole("progressbar"));
|
||||
|
||||
expect(getMessage(component)).toMatchSnapshot();
|
||||
expect(MatrixClientPeg.safeGet().lookupThreePid).toHaveBeenCalledWith(
|
||||
"email",
|
||||
invitedEmail,
|
||||
"mock-token",
|
||||
);
|
||||
await testJoinButton({ inviterName, invitedEmail })();
|
||||
});
|
||||
|
||||
it("renders invite message when invite email mxid match", async () => {
|
||||
MatrixClientPeg.safeGet().lookupThreePid = jest.fn().mockReturnValue({ mxid: userId });
|
||||
const component = getComponent({ inviterName, invitedEmail });
|
||||
await waitForElementToBeRemoved(() => component.queryByRole("progressbar"));
|
||||
|
||||
expect(getMessage(component)).toMatchSnapshot();
|
||||
await testJoinButton({ inviterName, invitedEmail }, false)();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("message case AskToJoin", () => {
|
||||
it("renders the corresponding message", () => {
|
||||
const component = getComponent({ promptAskToJoin: true });
|
||||
expect(getMessage(component)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders the corresponding message when kicked", () => {
|
||||
const room = createRoom(roomId, otherUserId);
|
||||
jest.spyOn(room, "getMember").mockReturnValue(makeMockRoomMember({ isKicked: true }));
|
||||
const component = getComponent({ room, promptAskToJoin: true });
|
||||
|
||||
expect(getMessage(component)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders the corresponding message with a generic title", () => {
|
||||
const component = render(<RoomPreviewBar promptAskToJoin />);
|
||||
expect(getMessage(component)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders the corresponding actions", () => {
|
||||
const component = getComponent({ promptAskToJoin: true });
|
||||
expect(getActions(component)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("triggers the primary action callback", () => {
|
||||
const onSubmitAskToJoin = jest.fn();
|
||||
const component = getComponent({ promptAskToJoin: true, onSubmitAskToJoin });
|
||||
|
||||
fireEvent.click(getPrimaryActionButton(component)!);
|
||||
expect(onSubmitAskToJoin).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("triggers the primary action callback with a reason", () => {
|
||||
const onSubmitAskToJoin = jest.fn();
|
||||
const reason = "some reason";
|
||||
const component = getComponent({ promptAskToJoin: true, onSubmitAskToJoin });
|
||||
|
||||
fireEvent.change(component.container.querySelector("textarea")!, { target: { value: reason } });
|
||||
fireEvent.click(getPrimaryActionButton(component)!);
|
||||
|
||||
expect(onSubmitAskToJoin).toHaveBeenCalledWith(reason);
|
||||
});
|
||||
});
|
||||
|
||||
describe("message case Knocked", () => {
|
||||
it("renders the corresponding message", () => {
|
||||
const component = getComponent({ knocked: true });
|
||||
expect(getMessage(component)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders the corresponding actions", () => {
|
||||
const component = getComponent({ knocked: true, onCancelAskToJoin: () => {} });
|
||||
expect(getActions(component)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("triggers the secondary action callback", () => {
|
||||
const onCancelAskToJoin = jest.fn();
|
||||
const component = getComponent({ knocked: true, onCancelAskToJoin });
|
||||
|
||||
fireEvent.click(getSecondaryActionButton(component)!);
|
||||
expect(onCancelAskToJoin).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,86 @@
|
|||
/*
|
||||
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 React from "react";
|
||||
import { mocked, Mocked } from "jest-mock";
|
||||
import { render, screen, act } from "jest-matrix-react";
|
||||
import { PendingEventOrdering, Room, RoomStateEvent, RoomType } from "matrix-js-sdk/src/matrix";
|
||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||
|
||||
import type { MatrixClient, RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
import { stubClient, wrapInMatrixClientContext, mkRoomMember } from "../../../../test-utils";
|
||||
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
|
||||
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
|
||||
import SettingsStore from "../../../../../src/settings/SettingsStore";
|
||||
import _RoomPreviewCard from "../../../../../src/components/views/rooms/RoomPreviewCard";
|
||||
|
||||
const RoomPreviewCard = wrapInMatrixClientContext(_RoomPreviewCard);
|
||||
|
||||
describe("RoomPreviewCard", () => {
|
||||
let client: Mocked<MatrixClient>;
|
||||
let room: Room;
|
||||
let alice: RoomMember;
|
||||
let enabledFeatures: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
stubClient();
|
||||
client = mocked(MatrixClientPeg.safeGet());
|
||||
client.getUserId.mockReturnValue("@alice:example.org");
|
||||
DMRoomMap.makeShared(client);
|
||||
|
||||
room = new Room("!1:example.org", client, "@alice:example.org", {
|
||||
pendingEventOrdering: PendingEventOrdering.Detached,
|
||||
});
|
||||
alice = mkRoomMember(room.roomId, "@alice:example.org");
|
||||
jest.spyOn(room, "getMember").mockImplementation((userId) => (userId === alice.userId ? alice : null));
|
||||
|
||||
client.getRoom.mockImplementation((roomId) => (roomId === room.roomId ? room : null));
|
||||
client.getRooms.mockReturnValue([room]);
|
||||
client.reEmitter.reEmit(room, [RoomStateEvent.Events]);
|
||||
|
||||
enabledFeatures = [];
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName) =>
|
||||
enabledFeatures.includes(settingName) ? true : undefined,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]);
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
const renderPreview = async (): Promise<void> => {
|
||||
render(<RoomPreviewCard room={room} onJoinButtonClicked={() => {}} onRejectButtonClicked={() => {}} />);
|
||||
await act(() => Promise.resolve()); // Allow effects to settle
|
||||
};
|
||||
|
||||
it("shows a beta pill on Jitsi video room invites", async () => {
|
||||
jest.spyOn(room, "getType").mockReturnValue(RoomType.ElementVideo);
|
||||
jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Invite);
|
||||
enabledFeatures = ["feature_video_rooms"];
|
||||
|
||||
await renderPreview();
|
||||
screen.getByRole("button", { name: /beta/i });
|
||||
});
|
||||
|
||||
it("shows a beta pill on Element video room invites", async () => {
|
||||
jest.spyOn(room, "getType").mockReturnValue(RoomType.UnstableCall);
|
||||
jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Invite);
|
||||
enabledFeatures = ["feature_video_rooms", "feature_element_call_video_rooms"];
|
||||
|
||||
await renderPreview();
|
||||
screen.getByRole("button", { name: /beta/i });
|
||||
});
|
||||
|
||||
it("doesn't show a beta pill on normal invites", async () => {
|
||||
jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Invite);
|
||||
|
||||
await renderPreview();
|
||||
expect(screen.queryByRole("button", { name: /beta/i })).toBeNull();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,87 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2024 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 { render, screen } from "jest-matrix-react";
|
||||
|
||||
import RoomSearchAuxPanel from "../../../../../src/components/views/rooms/RoomSearchAuxPanel";
|
||||
import { SearchScope } from "../../../../../src/Searching";
|
||||
|
||||
describe("RoomSearchAuxPanel", () => {
|
||||
it("should render the count of results", () => {
|
||||
render(
|
||||
<RoomSearchAuxPanel
|
||||
searchInfo={{
|
||||
searchId: 1234,
|
||||
count: 5,
|
||||
term: "abcd",
|
||||
scope: SearchScope.Room,
|
||||
promise: new Promise(() => {}),
|
||||
}}
|
||||
isRoomEncrypted={false}
|
||||
onSearchScopeChange={jest.fn()}
|
||||
onCancelClick={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("5 results found for", { exact: false })).toHaveTextContent(
|
||||
"5 results found for “abcd”",
|
||||
);
|
||||
});
|
||||
|
||||
it("should allow the user to toggle to all rooms search", async () => {
|
||||
const onSearchScopeChange = jest.fn();
|
||||
|
||||
render(
|
||||
<RoomSearchAuxPanel
|
||||
isRoomEncrypted={false}
|
||||
onSearchScopeChange={onSearchScopeChange}
|
||||
onCancelClick={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
screen.getByText("Search all rooms").click();
|
||||
expect(onSearchScopeChange).toHaveBeenCalledWith(SearchScope.All);
|
||||
});
|
||||
|
||||
it("should allow the user to toggle back to room-specific search", async () => {
|
||||
const onSearchScopeChange = jest.fn();
|
||||
|
||||
render(
|
||||
<RoomSearchAuxPanel
|
||||
searchInfo={{
|
||||
searchId: 1234,
|
||||
term: "abcd",
|
||||
scope: SearchScope.All,
|
||||
promise: new Promise(() => {}),
|
||||
}}
|
||||
isRoomEncrypted={false}
|
||||
onSearchScopeChange={onSearchScopeChange}
|
||||
onCancelClick={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
screen.getByText("Search this room").click();
|
||||
expect(onSearchScopeChange).toHaveBeenCalledWith(SearchScope.Room);
|
||||
});
|
||||
|
||||
it("should allow the user to cancel a search", async () => {
|
||||
const onCancelClick = jest.fn();
|
||||
|
||||
render(
|
||||
<RoomSearchAuxPanel
|
||||
isRoomEncrypted={false}
|
||||
onSearchScopeChange={jest.fn()}
|
||||
onCancelClick={onCancelClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
screen.getByRole("button", { name: "Cancel" }).click();
|
||||
expect(onCancelClick).toHaveBeenCalled();
|
||||
});
|
||||
});
|
409
test/unit-tests/components/views/rooms/RoomTile-test.tsx
Normal file
409
test/unit-tests/components/views/rooms/RoomTile-test.tsx
Normal file
|
@ -0,0 +1,409 @@
|
|||
/*
|
||||
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 React from "react";
|
||||
import { render, screen, act, RenderResult } from "jest-matrix-react";
|
||||
import { mocked, Mocked } from "jest-mock";
|
||||
import {
|
||||
MatrixClient,
|
||||
PendingEventOrdering,
|
||||
Room,
|
||||
MatrixEvent,
|
||||
RoomStateEvent,
|
||||
Thread,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||
import { Widget } from "matrix-widget-api";
|
||||
|
||||
import type { RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
import type { ClientWidgetApi } from "matrix-widget-api";
|
||||
import {
|
||||
stubClient,
|
||||
mkRoomMember,
|
||||
MockedCall,
|
||||
useMockedCalls,
|
||||
setupAsyncStoreWithClient,
|
||||
filterConsole,
|
||||
flushPromises,
|
||||
mkMessage,
|
||||
useMockMediaDevices,
|
||||
} from "../../../../test-utils";
|
||||
import { CallStore } from "../../../../../src/stores/CallStore";
|
||||
import RoomTile from "../../../../../src/components/views/rooms/RoomTile";
|
||||
import { DefaultTagID } from "../../../../../src/stores/room-list/models";
|
||||
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
|
||||
import PlatformPeg from "../../../../../src/PlatformPeg";
|
||||
import BasePlatform from "../../../../../src/BasePlatform";
|
||||
import { WidgetMessagingStore } from "../../../../../src/stores/widgets/WidgetMessagingStore";
|
||||
import { VoiceBroadcastInfoState } from "../../../../../src/voice-broadcast";
|
||||
import { mkVoiceBroadcastInfoStateEvent } from "../../../voice-broadcast/utils/test-utils";
|
||||
import { TestSdkContext } from "../../../TestSdkContext";
|
||||
import { SDKContext } from "../../../../../src/contexts/SDKContext";
|
||||
import { shouldShowComponent } from "../../../../../src/customisations/helpers/UIComponents";
|
||||
import { UIComponent } from "../../../../../src/settings/UIFeature";
|
||||
import { MessagePreviewStore } from "../../../../../src/stores/room-list/MessagePreviewStore";
|
||||
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
|
||||
import SettingsStore from "../../../../../src/settings/SettingsStore";
|
||||
import { ConnectionState } from "../../../../../src/models/Call";
|
||||
|
||||
jest.mock("../../../../../src/customisations/helpers/UIComponents", () => ({
|
||||
shouldShowComponent: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("RoomTile", () => {
|
||||
jest.spyOn(PlatformPeg, "get").mockReturnValue({
|
||||
overrideBrowserShortcuts: () => false,
|
||||
} as unknown as BasePlatform);
|
||||
useMockedCalls();
|
||||
|
||||
const setUpVoiceBroadcast = async (state: VoiceBroadcastInfoState): Promise<void> => {
|
||||
voiceBroadcastInfoEvent = mkVoiceBroadcastInfoStateEvent(
|
||||
room.roomId,
|
||||
state,
|
||||
client.getSafeUserId(),
|
||||
client.getDeviceId()!,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
room.currentState.setStateEvents([voiceBroadcastInfoEvent]);
|
||||
await flushPromises();
|
||||
});
|
||||
};
|
||||
|
||||
const renderRoomTile = (): RenderResult => {
|
||||
return render(
|
||||
<SDKContext.Provider value={sdkContext}>
|
||||
<RoomTile
|
||||
room={room}
|
||||
showMessagePreview={showMessagePreview}
|
||||
isMinimized={false}
|
||||
tag={DefaultTagID.Untagged}
|
||||
/>
|
||||
</SDKContext.Provider>,
|
||||
);
|
||||
};
|
||||
|
||||
let client: Mocked<MatrixClient>;
|
||||
let voiceBroadcastInfoEvent: MatrixEvent;
|
||||
let room: Room;
|
||||
let sdkContext: TestSdkContext;
|
||||
let showMessagePreview = false;
|
||||
|
||||
filterConsole(
|
||||
// irrelevant for this test
|
||||
"Room !1:example.org does not have an m.room.create event",
|
||||
);
|
||||
|
||||
const addMessageToRoom = (ts: number) => {
|
||||
const message = mkMessage({
|
||||
event: true,
|
||||
room: room.roomId,
|
||||
msg: "test message",
|
||||
user: client.getSafeUserId(),
|
||||
ts,
|
||||
});
|
||||
|
||||
room.timeline.push(message);
|
||||
};
|
||||
|
||||
const addThreadMessageToRoom = (ts: number) => {
|
||||
const message = mkMessage({
|
||||
event: true,
|
||||
room: room.roomId,
|
||||
msg: "test thread reply",
|
||||
user: client.getSafeUserId(),
|
||||
ts,
|
||||
});
|
||||
|
||||
// Mock thread reply for tests.
|
||||
jest.spyOn(room, "getThreads").mockReturnValue([
|
||||
// @ts-ignore
|
||||
{
|
||||
lastReply: () => message,
|
||||
timeline: [],
|
||||
} as Thread,
|
||||
]);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
useMockMediaDevices();
|
||||
sdkContext = new TestSdkContext();
|
||||
|
||||
client = mocked(stubClient());
|
||||
sdkContext.client = client;
|
||||
DMRoomMap.makeShared(client);
|
||||
|
||||
room = new Room("!1:example.org", client, "@alice:example.org", {
|
||||
pendingEventOrdering: PendingEventOrdering.Detached,
|
||||
});
|
||||
|
||||
client.getRoom.mockImplementation((roomId) => (roomId === room.roomId ? room : null));
|
||||
client.getRooms.mockReturnValue([room]);
|
||||
client.reEmitter.reEmit(room, [RoomStateEvent.Events]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// @ts-ignore
|
||||
MessagePreviewStore.instance.previews = new Map<string, Map<TagID | TAG_ANY, MessagePreview | null>>();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("when message previews are not enabled", () => {
|
||||
it("should render the room", () => {
|
||||
mocked(shouldShowComponent).mockReturnValue(true);
|
||||
const { container } = renderRoomTile();
|
||||
expect(container).toMatchSnapshot();
|
||||
expect(container.querySelector(".mx_RoomTile_sticky")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render the room options context menu when UIComponent customisations disable room options", () => {
|
||||
mocked(shouldShowComponent).mockReturnValue(false);
|
||||
renderRoomTile();
|
||||
expect(shouldShowComponent).toHaveBeenCalledWith(UIComponent.RoomOptionsMenu);
|
||||
expect(screen.queryByRole("button", { name: "Room options" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the room options context menu when UIComponent customisations enable room options", () => {
|
||||
mocked(shouldShowComponent).mockReturnValue(true);
|
||||
renderRoomTile();
|
||||
expect(shouldShowComponent).toHaveBeenCalledWith(UIComponent.RoomOptionsMenu);
|
||||
expect(screen.queryByRole("button", { name: "Room options" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render the room options context menu when knocked to the room", () => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((name) => {
|
||||
return name === "feature_ask_to_join";
|
||||
});
|
||||
mocked(shouldShowComponent).mockReturnValue(true);
|
||||
jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Knock);
|
||||
const { container } = renderRoomTile();
|
||||
expect(container.querySelector(".mx_RoomTile_sticky")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Room options" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render the room options context menu when knock has been denied", () => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((name) => {
|
||||
return name === "feature_ask_to_join";
|
||||
});
|
||||
mocked(shouldShowComponent).mockReturnValue(true);
|
||||
const roomMember = mkRoomMember(
|
||||
room.roomId,
|
||||
MatrixClientPeg.get()!.getSafeUserId(),
|
||||
KnownMembership.Leave,
|
||||
true,
|
||||
{
|
||||
membership: KnownMembership.Knock,
|
||||
},
|
||||
);
|
||||
jest.spyOn(room, "getMember").mockReturnValue(roomMember);
|
||||
const { container } = renderRoomTile();
|
||||
expect(container.querySelector(".mx_RoomTile_sticky")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Room options" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("when a call starts", () => {
|
||||
let call: MockedCall;
|
||||
let widget: Widget;
|
||||
|
||||
beforeEach(() => {
|
||||
setupAsyncStoreWithClient(CallStore.instance, client);
|
||||
setupAsyncStoreWithClient(WidgetMessagingStore.instance, client);
|
||||
|
||||
MockedCall.create(room, "1");
|
||||
const maybeCall = CallStore.instance.getCall(room.roomId);
|
||||
if (!(maybeCall instanceof MockedCall)) throw new Error("Failed to create call");
|
||||
call = maybeCall;
|
||||
|
||||
widget = new Widget(call.widget);
|
||||
WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, {
|
||||
stop: () => {},
|
||||
} as unknown as ClientWidgetApi);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
call.destroy();
|
||||
client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]);
|
||||
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
|
||||
});
|
||||
|
||||
it("tracks connection state", async () => {
|
||||
renderRoomTile();
|
||||
screen.getByText("Video");
|
||||
|
||||
let completeWidgetLoading: () => void = () => {};
|
||||
const widgetLoadingCompleted = new Promise<void>((resolve) => (completeWidgetLoading = resolve));
|
||||
|
||||
// Insert an await point in the connection method so we can inspect
|
||||
// the intermediate connecting state
|
||||
let completeConnection: () => void = () => {};
|
||||
const connectionCompleted = new Promise<void>((resolve) => (completeConnection = resolve));
|
||||
|
||||
let completeLobby: () => void = () => {};
|
||||
const lobbyCompleted = new Promise<void>((resolve) => (completeLobby = resolve));
|
||||
|
||||
jest.spyOn(call, "performConnection").mockImplementation(async () => {
|
||||
call.setConnectionState(ConnectionState.WidgetLoading);
|
||||
await widgetLoadingCompleted;
|
||||
call.setConnectionState(ConnectionState.Lobby);
|
||||
await lobbyCompleted;
|
||||
call.setConnectionState(ConnectionState.Connecting);
|
||||
await connectionCompleted;
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
(async () => {
|
||||
await screen.findByText("Loading…");
|
||||
completeWidgetLoading();
|
||||
await screen.findByText("Lobby");
|
||||
completeLobby();
|
||||
await screen.findByText("Joining…");
|
||||
completeConnection();
|
||||
await screen.findByText("Joined");
|
||||
})(),
|
||||
call.start(),
|
||||
]);
|
||||
|
||||
await Promise.all([screen.findByText("Video"), call.disconnect()]);
|
||||
});
|
||||
|
||||
it("tracks participants", () => {
|
||||
renderRoomTile();
|
||||
const alice: [RoomMember, Set<string>] = [
|
||||
mkRoomMember(room.roomId, "@alice:example.org"),
|
||||
new Set(["a"]),
|
||||
];
|
||||
const bob: [RoomMember, Set<string>] = [
|
||||
mkRoomMember(room.roomId, "@bob:example.org"),
|
||||
new Set(["b1", "b2"]),
|
||||
];
|
||||
const carol: [RoomMember, Set<string>] = [
|
||||
mkRoomMember(room.roomId, "@carol:example.org"),
|
||||
new Set(["c"]),
|
||||
];
|
||||
|
||||
expect(screen.queryByLabelText(/participant/)).toBe(null);
|
||||
|
||||
act(() => {
|
||||
call.participants = new Map([alice]);
|
||||
});
|
||||
expect(screen.getByLabelText("1 person joined").textContent).toBe("1");
|
||||
|
||||
act(() => {
|
||||
call.participants = new Map([alice, bob, carol]);
|
||||
});
|
||||
expect(screen.getByLabelText("4 people joined").textContent).toBe("4");
|
||||
|
||||
act(() => {
|
||||
call.participants = new Map();
|
||||
});
|
||||
expect(screen.queryByLabelText(/participant/)).toBe(null);
|
||||
});
|
||||
|
||||
describe("and a live broadcast starts", () => {
|
||||
beforeEach(async () => {
|
||||
renderRoomTile();
|
||||
await setUpVoiceBroadcast(VoiceBroadcastInfoState.Started);
|
||||
});
|
||||
|
||||
it("should still render the call subtitle", () => {
|
||||
expect(screen.queryByText("Video")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Live")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when a live voice broadcast starts", () => {
|
||||
beforeEach(async () => {
|
||||
renderRoomTile();
|
||||
await setUpVoiceBroadcast(VoiceBroadcastInfoState.Started);
|
||||
});
|
||||
|
||||
it("should render the »Live« subtitle", () => {
|
||||
expect(screen.queryByText("Live")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("and the broadcast stops", () => {
|
||||
beforeEach(async () => {
|
||||
const stopEvent = mkVoiceBroadcastInfoStateEvent(
|
||||
room.roomId,
|
||||
VoiceBroadcastInfoState.Stopped,
|
||||
client.getSafeUserId(),
|
||||
client.getDeviceId()!,
|
||||
voiceBroadcastInfoEvent,
|
||||
);
|
||||
await act(async () => {
|
||||
room.currentState.setStateEvents([stopEvent]);
|
||||
await flushPromises();
|
||||
});
|
||||
});
|
||||
|
||||
it("should not render the »Live« subtitle", () => {
|
||||
expect(screen.queryByText("Live")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when message previews are enabled", () => {
|
||||
beforeEach(() => {
|
||||
showMessagePreview = true;
|
||||
});
|
||||
|
||||
it("should render a room without a message as expected", async () => {
|
||||
const renderResult = renderRoomTile();
|
||||
// flush promises here because the preview is created asynchronously
|
||||
await flushPromises();
|
||||
expect(renderResult.asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe("and there is a message in the room", () => {
|
||||
beforeEach(() => {
|
||||
addMessageToRoom(23);
|
||||
});
|
||||
|
||||
it("should render as expected", async () => {
|
||||
const renderResult = renderRoomTile();
|
||||
expect(await screen.findByText("test message")).toBeInTheDocument();
|
||||
expect(renderResult.asFragment()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe("and there is a message in a thread", () => {
|
||||
beforeEach(() => {
|
||||
addThreadMessageToRoom(23);
|
||||
});
|
||||
|
||||
it("should render as expected", async () => {
|
||||
const renderResult = renderRoomTile();
|
||||
expect(await screen.findByText("test thread reply")).toBeInTheDocument();
|
||||
expect(renderResult.asFragment()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe("and there is a message and a thread without a reply", () => {
|
||||
beforeEach(() => {
|
||||
addMessageToRoom(23);
|
||||
|
||||
// Mock thread reply for tests.
|
||||
jest.spyOn(room, "getThreads").mockReturnValue([
|
||||
// @ts-ignore
|
||||
{
|
||||
lastReply: () => null,
|
||||
timeline: [],
|
||||
findEventById: () => {},
|
||||
} as Thread,
|
||||
]);
|
||||
});
|
||||
|
||||
it("should render the message preview", async () => {
|
||||
renderRoomTile();
|
||||
expect(await screen.findByText("test message")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,98 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022, 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 * as React from "react";
|
||||
import { MatrixEvent, Room, EventType } from "matrix-js-sdk/src/matrix";
|
||||
import { render, type RenderResult } from "jest-matrix-react";
|
||||
|
||||
import { stubClient } from "../../../../test-utils";
|
||||
import SearchResultTile from "../../../../../src/components/views/rooms/SearchResultTile";
|
||||
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
|
||||
|
||||
const ROOM_ID = "!qPewotXpIctQySfjSy:localhost";
|
||||
|
||||
type Props = React.ComponentPropsWithoutRef<typeof SearchResultTile>;
|
||||
|
||||
describe("SearchResultTile", () => {
|
||||
beforeAll(() => {
|
||||
stubClient();
|
||||
const cli = MatrixClientPeg.safeGet();
|
||||
|
||||
const room = new Room(ROOM_ID, cli, "@bob:example.org");
|
||||
jest.spyOn(cli, "getRoom").mockReturnValue(room);
|
||||
});
|
||||
|
||||
function renderComponent(props: Partial<Props>): RenderResult {
|
||||
return render(<SearchResultTile timeline={[]} ourEventsIndexes={[1]} {...props} />);
|
||||
}
|
||||
|
||||
it("Sets up appropriate callEventGrouper for m.call. events", () => {
|
||||
const { container } = renderComponent({
|
||||
timeline: [
|
||||
new MatrixEvent({
|
||||
type: EventType.CallInvite,
|
||||
sender: "@user1:server",
|
||||
room_id: ROOM_ID,
|
||||
origin_server_ts: 1432735824652,
|
||||
content: { call_id: "call.1" },
|
||||
event_id: "$1:server",
|
||||
}),
|
||||
new MatrixEvent({
|
||||
content: {
|
||||
body: "This is an example text message",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: "<b>This is an example text message</b>",
|
||||
msgtype: "m.text",
|
||||
},
|
||||
event_id: "$144429830826TWwbB:localhost",
|
||||
origin_server_ts: 1432735824653,
|
||||
room_id: ROOM_ID,
|
||||
sender: "@example:example.org",
|
||||
type: "m.room.message",
|
||||
unsigned: {
|
||||
age: 1234,
|
||||
},
|
||||
}),
|
||||
new MatrixEvent({
|
||||
type: EventType.CallAnswer,
|
||||
sender: "@user2:server",
|
||||
room_id: ROOM_ID,
|
||||
origin_server_ts: 1432735824654,
|
||||
content: { call_id: "call.1" },
|
||||
event_id: "$2:server",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const tiles = container.querySelectorAll<HTMLElement>(".mx_EventTile");
|
||||
expect(tiles.length).toEqual(2);
|
||||
expect(tiles[0]!.dataset.eventId).toBe("$1:server");
|
||||
expect(tiles[1]!.dataset.eventId).toBe("$144429830826TWwbB:localhost");
|
||||
});
|
||||
|
||||
it("supports events with missing timestamps", () => {
|
||||
const { container } = renderComponent({
|
||||
timeline: [...Array(20)].map(
|
||||
(_, i) =>
|
||||
new MatrixEvent({
|
||||
type: EventType.RoomMessage,
|
||||
sender: "@user1:server",
|
||||
room_id: ROOM_ID,
|
||||
content: { body: `Message #${i}` },
|
||||
event_id: `$${i}:server`,
|
||||
origin_server_ts: i,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const separators = container.querySelectorAll(".mx_TimelineSeparator");
|
||||
// One separator is always rendered at the top, we don't want any
|
||||
// between messages.
|
||||
expect(separators.length).toBe(1);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,595 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2020 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, waitFor } from "jest-matrix-react";
|
||||
import { IContent, MatrixClient, MsgType } from "matrix-js-sdk/src/matrix";
|
||||
import { mocked } from "jest-mock";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import SendMessageComposer, {
|
||||
attachMentions,
|
||||
createMessageContent,
|
||||
isQuickReaction,
|
||||
} from "../../../../../src/components/views/rooms/SendMessageComposer";
|
||||
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
|
||||
import RoomContext, { TimelineRenderingType } from "../../../../../src/contexts/RoomContext";
|
||||
import EditorModel from "../../../../../src/editor/model";
|
||||
import { createPartCreator } from "../../../editor/mock";
|
||||
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 { IRoomState, MainSplitContentType } from "../../../../../src/components/structures/RoomView";
|
||||
import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks";
|
||||
import { mockPlatformPeg } from "../../../../test-utils/platform";
|
||||
import { doMaybeLocalRoomAction } from "../../../../../src/utils/local-room";
|
||||
import { addTextToComposer } from "../../../../test-utils/composer";
|
||||
|
||||
jest.mock("../../../../../src/utils/local-room", () => ({
|
||||
doMaybeLocalRoomAction: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("<SendMessageComposer/>", () => {
|
||||
const defaultRoomContext: IRoomState = {
|
||||
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,
|
||||
userTimezone: undefined,
|
||||
readMarkerInViewThresholdMs: 3000,
|
||||
readMarkerOutOfViewThresholdMs: 30000,
|
||||
showHiddenEvents: false,
|
||||
showReadReceipts: true,
|
||||
showRedactions: true,
|
||||
showJoinLeaves: true,
|
||||
showAvatarChanges: true,
|
||||
showDisplaynameChanges: true,
|
||||
matrixClientIsReady: false,
|
||||
timelineRenderingType: TimelineRenderingType.Room,
|
||||
mainSplitContentType: MainSplitContentType.Timeline,
|
||||
liveTimeline: undefined,
|
||||
canSelfRedact: false,
|
||||
resizing: false,
|
||||
narrow: false,
|
||||
activeCall: null,
|
||||
msc3946ProcessDynamicPredecessor: false,
|
||||
canAskToJoin: false,
|
||||
promptAskToJoin: false,
|
||||
viewRoomOpts: { buttons: [] },
|
||||
};
|
||||
describe("createMessageContent", () => {
|
||||
const permalinkCreator = jest.fn() as any;
|
||||
|
||||
it("sends plaintext messages correctly", () => {
|
||||
const model = new EditorModel([], createPartCreator());
|
||||
const documentOffset = new DocumentOffset(11, true);
|
||||
model.update("hello world", "insertText", documentOffset);
|
||||
|
||||
const content = createMessageContent("@alice:test", model, undefined, undefined, permalinkCreator);
|
||||
|
||||
expect(content).toEqual({
|
||||
"body": "hello world",
|
||||
"msgtype": "m.text",
|
||||
"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 = createMessageContent("@alice:test", model, undefined, undefined, permalinkCreator);
|
||||
|
||||
expect(content).toEqual({
|
||||
"body": "hello *world*",
|
||||
"msgtype": "m.text",
|
||||
"format": "org.matrix.custom.html",
|
||||
"formatted_body": "hello <em>world</em>",
|
||||
"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 = createMessageContent("@alice:test", model, undefined, undefined, permalinkCreator);
|
||||
|
||||
expect(content).toEqual({
|
||||
"body": "blinks __quickly__",
|
||||
"msgtype": "m.emote",
|
||||
"format": "org.matrix.custom.html",
|
||||
"formatted_body": "blinks <strong>quickly</strong>",
|
||||
"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 = createMessageContent("@alice:test", model, undefined, undefined, permalinkCreator);
|
||||
|
||||
expect(content).toEqual({
|
||||
"body": "✨sparkles✨",
|
||||
"msgtype": "m.emote",
|
||||
"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 = createMessageContent("@alice:test", model, undefined, undefined, permalinkCreator);
|
||||
|
||||
expect(content).toEqual({
|
||||
"body": "/dev/null is my favourite place",
|
||||
"msgtype": "m.text",
|
||||
"m.mentions": {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("attachMentions", () => {
|
||||
const partsCreator = createPartCreator();
|
||||
|
||||
it("no mentions", () => {
|
||||
const model = new EditorModel([], partsCreator);
|
||||
const content: IContent = {};
|
||||
attachMentions("@alice:test", content, model, undefined);
|
||||
expect(content).toEqual({
|
||||
"m.mentions": {},
|
||||
});
|
||||
});
|
||||
|
||||
it("test user mentions", () => {
|
||||
const model = new EditorModel([partsCreator.userPill("Bob", "@bob:test")], partsCreator);
|
||||
const content: IContent = {};
|
||||
attachMentions("@alice:test", content, model, undefined);
|
||||
expect(content).toEqual({
|
||||
"m.mentions": { user_ids: ["@bob:test"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("test reply", () => {
|
||||
// Replying to an event adds the sender to the list of mentioned users.
|
||||
const model = new EditorModel([], partsCreator);
|
||||
let replyToEvent = mkEvent({
|
||||
type: "m.room.message",
|
||||
user: "@bob:test",
|
||||
room: "!abc:test",
|
||||
content: { "m.mentions": {} },
|
||||
event: true,
|
||||
});
|
||||
let content: IContent = {};
|
||||
attachMentions("@alice:test", content, model, replyToEvent);
|
||||
expect(content).toEqual({
|
||||
"m.mentions": { user_ids: ["@bob:test"] },
|
||||
});
|
||||
|
||||
// It also adds any other mentioned users, but removes yourself.
|
||||
replyToEvent = mkEvent({
|
||||
type: "m.room.message",
|
||||
user: "@bob:test",
|
||||
room: "!abc:test",
|
||||
content: { "m.mentions": { user_ids: ["@alice:test", "@charlie:test"] } },
|
||||
event: true,
|
||||
});
|
||||
content = {};
|
||||
attachMentions("@alice:test", content, model, replyToEvent);
|
||||
expect(content).toEqual({
|
||||
"m.mentions": { user_ids: ["@bob:test", "@charlie:test"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("test room mention", () => {
|
||||
const model = new EditorModel([partsCreator.atRoomPill("@room")], partsCreator);
|
||||
const content: IContent = {};
|
||||
attachMentions("@alice:test", content, model, undefined);
|
||||
expect(content).toEqual({
|
||||
"m.mentions": { room: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("test reply to room mention", () => {
|
||||
// Replying to a room mention shouldn't automatically be a room mention.
|
||||
const model = new EditorModel([], partsCreator);
|
||||
const replyToEvent = mkEvent({
|
||||
type: "m.room.message",
|
||||
user: "@alice:test",
|
||||
room: "!abc:test",
|
||||
content: { "m.mentions": { room: true } },
|
||||
event: true,
|
||||
});
|
||||
const content: IContent = {};
|
||||
attachMentions("@alice:test", content, model, replyToEvent);
|
||||
expect(content).toEqual({
|
||||
"m.mentions": {},
|
||||
});
|
||||
});
|
||||
|
||||
it("test broken mentions", () => {
|
||||
// Replying to a room mention shouldn't automatically be a room mention.
|
||||
const model = new EditorModel([], partsCreator);
|
||||
const replyToEvent = mkEvent({
|
||||
type: "m.room.message",
|
||||
user: "@alice:test",
|
||||
room: "!abc:test",
|
||||
// @ts-ignore - Purposefully testing invalid data.
|
||||
content: { "m.mentions": { user_ids: "@bob:test" } },
|
||||
event: true,
|
||||
});
|
||||
const content: IContent = {};
|
||||
attachMentions("@alice:test", content, model, replyToEvent);
|
||||
expect(content).toEqual({
|
||||
"m.mentions": {},
|
||||
});
|
||||
});
|
||||
|
||||
describe("attachMentions with edit", () => {
|
||||
it("no mentions", () => {
|
||||
const model = new EditorModel([], partsCreator);
|
||||
const content: IContent = { "m.new_content": {} };
|
||||
const prevContent: IContent = {};
|
||||
attachMentions("@alice:test", content, model, undefined, prevContent);
|
||||
expect(content).toEqual({
|
||||
"m.mentions": {},
|
||||
"m.new_content": { "m.mentions": {} },
|
||||
});
|
||||
});
|
||||
|
||||
it("mentions do not propagate", () => {
|
||||
const model = new EditorModel([], partsCreator);
|
||||
const content: IContent = { "m.new_content": {} };
|
||||
const prevContent: IContent = {
|
||||
"m.mentions": { user_ids: ["@bob:test"], room: true },
|
||||
};
|
||||
attachMentions("@alice:test", content, model, undefined, prevContent);
|
||||
expect(content).toEqual({
|
||||
"m.mentions": {},
|
||||
"m.new_content": { "m.mentions": {} },
|
||||
});
|
||||
});
|
||||
|
||||
it("test user mentions", () => {
|
||||
const model = new EditorModel([partsCreator.userPill("Bob", "@bob:test")], partsCreator);
|
||||
const content: IContent = { "m.new_content": {} };
|
||||
const prevContent: IContent = {};
|
||||
attachMentions("@alice:test", content, model, undefined, prevContent);
|
||||
expect(content).toEqual({
|
||||
"m.mentions": { user_ids: ["@bob:test"] },
|
||||
"m.new_content": { "m.mentions": { user_ids: ["@bob:test"] } },
|
||||
});
|
||||
});
|
||||
|
||||
it("test prev user mentions", () => {
|
||||
const model = new EditorModel([partsCreator.userPill("Bob", "@bob:test")], partsCreator);
|
||||
const content: IContent = { "m.new_content": {} };
|
||||
const prevContent: IContent = { "m.mentions": { user_ids: ["@bob:test"] } };
|
||||
attachMentions("@alice:test", content, model, undefined, prevContent);
|
||||
expect(content).toEqual({
|
||||
"m.mentions": {},
|
||||
"m.new_content": { "m.mentions": { user_ids: ["@bob:test"] } },
|
||||
});
|
||||
});
|
||||
|
||||
it("test room mention", () => {
|
||||
const model = new EditorModel([partsCreator.atRoomPill("@room")], partsCreator);
|
||||
const content: IContent = { "m.new_content": {} };
|
||||
const prevContent: IContent = {};
|
||||
attachMentions("@alice:test", content, model, undefined, prevContent);
|
||||
expect(content).toEqual({
|
||||
"m.mentions": { room: true },
|
||||
"m.new_content": { "m.mentions": { room: true } },
|
||||
});
|
||||
});
|
||||
|
||||
it("test prev room mention", () => {
|
||||
const model = new EditorModel([partsCreator.atRoomPill("@room")], partsCreator);
|
||||
const content: IContent = { "m.new_content": {} };
|
||||
const prevContent: IContent = { "m.mentions": { room: true } };
|
||||
attachMentions("@alice:test", content, model, undefined, prevContent);
|
||||
expect(content).toEqual({
|
||||
"m.mentions": {},
|
||||
"m.new_content": { "m.mentions": { room: true } },
|
||||
});
|
||||
});
|
||||
|
||||
it("test broken mentions", () => {
|
||||
// Replying to a room mention shouldn't automatically be a room mention.
|
||||
const model = new EditorModel([], partsCreator);
|
||||
const content: IContent = { "m.new_content": {} };
|
||||
// @ts-ignore - Purposefully testing invalid data.
|
||||
const prevContent: IContent = { "m.mentions": { user_ids: "@bob:test" } };
|
||||
attachMentions("@alice:test", content, model, undefined, prevContent);
|
||||
expect(content).toEqual({
|
||||
"m.mentions": {},
|
||||
"m.new_content": { "m.mentions": {} },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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 defaultProps = {
|
||||
room: mockRoom,
|
||||
toggleStickerPickerOpen: jest.fn(),
|
||||
permalinkCreator: new RoomPermalinkCreator(mockRoom),
|
||||
};
|
||||
const getRawComponent = (props = {}, roomContext = defaultRoomContext, client = mockClient) => (
|
||||
<MatrixClientContext.Provider value={client}>
|
||||
<RoomContext.Provider value={roomContext}>
|
||||
<SendMessageComposer {...defaultProps} {...props} />
|
||||
</RoomContext.Provider>
|
||||
</MatrixClientContext.Provider>
|
||||
);
|
||||
const getComponent = (props = {}, roomContext = defaultRoomContext, client = mockClient) => {
|
||||
return render(getRawComponent(props, roomContext, client));
|
||||
};
|
||||
|
||||
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 };
|
||||
const { container, unmount, rerender } = 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({
|
||||
parts: [{ type: "plain", text: "Test Text" }],
|
||||
replyEventId: mockEvent.getId(),
|
||||
});
|
||||
|
||||
// ensure the correct model is re-loaded
|
||||
rerender(getRawComponent(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);
|
||||
rerender(getRawComponent(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({
|
||||
parts: [{ type: "plain", text: "Hello World" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("persists to session history upon sending", async () => {
|
||||
mockPlatformPeg({ overrideBrowserShortcuts: jest.fn().mockReturnValue(false) });
|
||||
|
||||
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({
|
||||
parts: [{ type: "plain", text: "This is a message" }],
|
||||
replyEventId: mockEvent.getId(),
|
||||
});
|
||||
});
|
||||
|
||||
it("correctly sends a message", () => {
|
||||
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, "test message");
|
||||
fireEvent.keyDown(container.querySelector(".mx_SendMessageComposer")!, { key: "Enter" });
|
||||
|
||||
expect(mockClient.sendMessage).toHaveBeenCalledWith("myfakeroom", null, {
|
||||
"body": "test message",
|
||||
"msgtype": MsgType.Text,
|
||||
"m.mentions": {},
|
||||
});
|
||||
});
|
||||
|
||||
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, {
|
||||
"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, {
|
||||
"body": "test message",
|
||||
"msgtype": MsgType.Text,
|
||||
"m.mentions": {},
|
||||
});
|
||||
|
||||
expect(defaultDispatcher.dispatch).not.toHaveBeenCalledWith({ action: `effects.confetti` });
|
||||
});
|
||||
});
|
||||
|
||||
describe("isQuickReaction", () => {
|
||||
it("correctly detects quick reaction", () => {
|
||||
const model = new EditorModel([], createPartCreator());
|
||||
model.update("+😊", "insertText", new DocumentOffset(3, true));
|
||||
|
||||
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));
|
||||
|
||||
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());
|
||||
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));
|
||||
|
||||
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();
|
||||
cli.isCryptoEnabled = jest.fn().mockReturnValue(true);
|
||||
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}>
|
||||
<SendMessageComposer room={room} toggleStickerPickerOpen={jest.fn()} />
|
||||
</MatrixClientContext.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();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
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 { render, screen } from "jest-matrix-react";
|
||||
import { EventType, IEvent, MatrixEvent, Room, RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import ThirdPartyMemberInfo from "../../../../../src/components/views/rooms/ThirdPartyMemberInfo";
|
||||
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../../../test-utils";
|
||||
|
||||
describe("<ThirdPartyMemberInfo />", () => {
|
||||
const userId = "@alice:server.org";
|
||||
const roomId = "!room:server.org";
|
||||
const mockClient = getMockClientWithEventEmitter({
|
||||
...mockClientMethodsUser(userId),
|
||||
getRoom: jest.fn(),
|
||||
});
|
||||
|
||||
// make invite event with defaults
|
||||
const makeInviteEvent = (props: Partial<IEvent> = {}): MatrixEvent =>
|
||||
new MatrixEvent({
|
||||
type: EventType.RoomThirdPartyInvite,
|
||||
state_key: "123456",
|
||||
sender: userId,
|
||||
room_id: roomId,
|
||||
content: {
|
||||
display_name: "bob@bob.com",
|
||||
key_validity_url: "https://isthiskeyvalid.org",
|
||||
public_key: "abc123",
|
||||
},
|
||||
...props,
|
||||
});
|
||||
const defaultEvent = makeInviteEvent();
|
||||
|
||||
const getComponent = (event: MatrixEvent = defaultEvent) => render(<ThirdPartyMemberInfo event={event} />);
|
||||
const room = new Room(roomId, mockClient, userId);
|
||||
const aliceMember = new RoomMember(roomId, userId);
|
||||
aliceMember.name = "Alice DisplayName";
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(room, "getMember").mockImplementation((id) => (id === userId ? aliceMember : null));
|
||||
mockClient.getRoom.mockClear().mockReturnValue(room);
|
||||
});
|
||||
|
||||
it("should render invite", () => {
|
||||
const { container } = getComponent();
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should render invite when room in not available", () => {
|
||||
const event = makeInviteEvent({ room_id: "not_available" });
|
||||
const { container } = getComponent(event);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should use inviter's id when room member is not available", () => {
|
||||
const event = makeInviteEvent({ sender: "@charlie:server.org" });
|
||||
getComponent(event);
|
||||
|
||||
expect(screen.getByText("Invited by @charlie:server.org")).toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,183 @@
|
|||
/*
|
||||
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 React, { createRef, RefObject } from "react";
|
||||
import { render } from "jest-matrix-react";
|
||||
import { MatrixClient, MsgType, Room } from "matrix-js-sdk/src/matrix";
|
||||
import { mocked } from "jest-mock";
|
||||
|
||||
import VoiceRecordComposerTile from "../../../../../src/components/views/rooms/VoiceRecordComposerTile";
|
||||
import { doMaybeLocalRoomAction } from "../../../../../src/utils/local-room";
|
||||
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
|
||||
import { IUpload, VoiceMessageRecording } from "../../../../../src/audio/VoiceMessageRecording";
|
||||
import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks";
|
||||
import { VoiceRecordingStore } from "../../../../../src/stores/VoiceRecordingStore";
|
||||
import { PlaybackClock } from "../../../../../src/audio/PlaybackClock";
|
||||
import { mkEvent } from "../../../../test-utils";
|
||||
|
||||
jest.mock("../../../../../src/utils/local-room", () => ({
|
||||
doMaybeLocalRoomAction: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../../../../../src/stores/VoiceRecordingStore", () => ({
|
||||
VoiceRecordingStore: {
|
||||
getVoiceRecordingId: jest.fn().mockReturnValue("voice-recording-id"),
|
||||
instance: {
|
||||
getActiveRecording: jest.fn(),
|
||||
disposeRecording: jest.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("<VoiceRecordComposerTile/>", () => {
|
||||
let voiceRecordComposerTile: RefObject<VoiceRecordComposerTile>;
|
||||
let mockRecorder: VoiceMessageRecording;
|
||||
let mockUpload: IUpload;
|
||||
let mockClient: MatrixClient;
|
||||
const roomId = "!room:example.com";
|
||||
|
||||
beforeEach(() => {
|
||||
mockClient = {
|
||||
getSafeUserId: jest.fn().mockReturnValue("@alice:example.com"),
|
||||
sendMessage: jest.fn(),
|
||||
} as unknown as MatrixClient;
|
||||
MatrixClientPeg.get = () => mockClient;
|
||||
MatrixClientPeg.safeGet = () => mockClient;
|
||||
|
||||
const room = {
|
||||
roomId,
|
||||
} as unknown as Room;
|
||||
|
||||
voiceRecordComposerTile = createRef();
|
||||
const props = {
|
||||
room,
|
||||
ref: voiceRecordComposerTile,
|
||||
permalinkCreator: new RoomPermalinkCreator(room),
|
||||
};
|
||||
mockUpload = {
|
||||
mxc: "mxc://example.com/voice",
|
||||
};
|
||||
mockRecorder = {
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
stop: jest.fn(),
|
||||
upload: () => Promise.resolve(mockUpload),
|
||||
durationSeconds: 1337,
|
||||
contentType: "audio/ogg",
|
||||
getPlayback: () => ({
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
prepare: jest.fn().mockResolvedValue(void 0),
|
||||
clockInfo: {
|
||||
timeSeconds: 0,
|
||||
liveData: {
|
||||
onUpdate: jest.fn(),
|
||||
},
|
||||
} as unknown as PlaybackClock,
|
||||
waveform: [1.4, 2.5, 3.6],
|
||||
waveformData: {
|
||||
onUpdate: jest.fn(),
|
||||
},
|
||||
thumbnailWaveform: [1.4, 2.5, 3.6],
|
||||
}),
|
||||
} as unknown as VoiceMessageRecording;
|
||||
mocked(VoiceRecordingStore.instance.getActiveRecording).mockReturnValue(mockRecorder);
|
||||
render(<VoiceRecordComposerTile {...props} />);
|
||||
|
||||
mocked(doMaybeLocalRoomAction).mockImplementation(
|
||||
<T,>(roomId: string, fn: (actualRoomId: string) => Promise<T>, _client?: MatrixClient) => {
|
||||
return fn(roomId);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("send", () => {
|
||||
it("should send the voice recording", async () => {
|
||||
await voiceRecordComposerTile.current!.send();
|
||||
expect(mockClient.sendMessage).toHaveBeenCalledWith(roomId, {
|
||||
"body": "Voice message",
|
||||
"file": undefined,
|
||||
"info": {
|
||||
duration: 1337000,
|
||||
mimetype: "audio/ogg",
|
||||
size: undefined,
|
||||
},
|
||||
"msgtype": MsgType.Audio,
|
||||
"org.matrix.msc1767.audio": {
|
||||
duration: 1337000,
|
||||
waveform: [1434, 2560, 3686],
|
||||
},
|
||||
"org.matrix.msc1767.file": {
|
||||
file: undefined,
|
||||
mimetype: "audio/ogg",
|
||||
name: "Voice message.ogg",
|
||||
size: undefined,
|
||||
url: "mxc://example.com/voice",
|
||||
},
|
||||
"org.matrix.msc1767.text": "Voice message",
|
||||
"org.matrix.msc3245.voice": {},
|
||||
"url": "mxc://example.com/voice",
|
||||
"m.mentions": {},
|
||||
});
|
||||
});
|
||||
|
||||
it("reply with voice recording", async () => {
|
||||
const room = {
|
||||
roomId,
|
||||
} as unknown as Room;
|
||||
|
||||
const replyToEvent = mkEvent({
|
||||
type: "m.room.message",
|
||||
user: "@bob:test",
|
||||
room: roomId,
|
||||
content: {},
|
||||
event: true,
|
||||
});
|
||||
|
||||
const props = {
|
||||
room,
|
||||
ref: voiceRecordComposerTile,
|
||||
permalinkCreator: new RoomPermalinkCreator(room),
|
||||
replyToEvent,
|
||||
};
|
||||
render(<VoiceRecordComposerTile {...props} />);
|
||||
|
||||
await voiceRecordComposerTile.current!.send();
|
||||
expect(mockClient.sendMessage).toHaveBeenCalledWith(roomId, {
|
||||
"body": "Voice message",
|
||||
"file": undefined,
|
||||
"info": {
|
||||
duration: 1337000,
|
||||
mimetype: "audio/ogg",
|
||||
size: undefined,
|
||||
},
|
||||
"msgtype": MsgType.Audio,
|
||||
"org.matrix.msc1767.audio": {
|
||||
duration: 1337000,
|
||||
waveform: [1434, 2560, 3686],
|
||||
},
|
||||
"org.matrix.msc1767.file": {
|
||||
file: undefined,
|
||||
mimetype: "audio/ogg",
|
||||
name: "Voice message.ogg",
|
||||
size: undefined,
|
||||
url: "mxc://example.com/voice",
|
||||
},
|
||||
"org.matrix.msc1767.text": "Voice message",
|
||||
"org.matrix.msc3245.voice": {},
|
||||
"url": "mxc://example.com/voice",
|
||||
"m.relates_to": {
|
||||
"m.in_reply_to": {
|
||||
event_id: replyToEvent.getId(),
|
||||
},
|
||||
},
|
||||
"m.mentions": { user_ids: ["@bob:test"] },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,39 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ExtraTile renders 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
aria-label="test"
|
||||
class="mx_AccessibleButton mx_ExtraTile mx_RoomTile"
|
||||
role="treeitem"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="mx_RoomTile_avatarContainer"
|
||||
/>
|
||||
<div
|
||||
class="mx_RoomTile_details"
|
||||
>
|
||||
<div
|
||||
class="mx_RoomTile_primaryDetails"
|
||||
>
|
||||
<div
|
||||
class="mx_RoomTile_titleContainer"
|
||||
>
|
||||
<div
|
||||
class="mx_RoomTile_title"
|
||||
dir="auto"
|
||||
tabindex="-1"
|
||||
title="test"
|
||||
>
|
||||
test
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mx_RoomTile_badgeContainer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
|
@ -0,0 +1,160 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`MemberTile should display an verified E2EIcon when the e2E status = Verified 1`] = `
|
||||
<div>
|
||||
<div>
|
||||
<div
|
||||
aria-label="@userId:matrix.org (power 0)"
|
||||
class="mx_AccessibleButton mx_EntityTile mx_EntityTile_offline_neveractive"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="mx_EntityTile_avatar"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61"
|
||||
data-color="2"
|
||||
data-testid="avatar-img"
|
||||
data-type="round"
|
||||
role="presentation"
|
||||
style="--cpd-avatar-size: 36px;"
|
||||
title="@userId:matrix.org"
|
||||
>
|
||||
u
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="mx_EntityTile_details"
|
||||
>
|
||||
<div
|
||||
class="mx_EntityTile_name"
|
||||
>
|
||||
<div
|
||||
class="mx_DisambiguatedProfile"
|
||||
>
|
||||
<span
|
||||
class=""
|
||||
dir="auto"
|
||||
>
|
||||
@userId:matrix.org
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mx_PresenceLabel"
|
||||
>
|
||||
Offline
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`MemberTile should display an warning E2EIcon when the e2E status = Warning 1`] = `
|
||||
<div>
|
||||
<div>
|
||||
<div
|
||||
aria-label="@userId:matrix.org (power 0)"
|
||||
class="mx_AccessibleButton mx_EntityTile mx_EntityTile_offline_neveractive"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="mx_EntityTile_avatar"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61"
|
||||
data-color="2"
|
||||
data-testid="avatar-img"
|
||||
data-type="round"
|
||||
role="presentation"
|
||||
style="--cpd-avatar-size: 36px;"
|
||||
title="@userId:matrix.org"
|
||||
>
|
||||
u
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="mx_EntityTile_details"
|
||||
>
|
||||
<div
|
||||
class="mx_EntityTile_name"
|
||||
>
|
||||
<div
|
||||
class="mx_DisambiguatedProfile"
|
||||
>
|
||||
<span
|
||||
class=""
|
||||
dir="auto"
|
||||
>
|
||||
@userId:matrix.org
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mx_PresenceLabel"
|
||||
>
|
||||
Offline
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`MemberTile should not display an E2EIcon when the e2E status = normal 1`] = `
|
||||
<div>
|
||||
<div>
|
||||
<div
|
||||
aria-label="@userId:matrix.org (power 0)"
|
||||
class="mx_AccessibleButton mx_EntityTile mx_EntityTile_offline_neveractive"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="mx_EntityTile_avatar"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61"
|
||||
data-color="2"
|
||||
data-testid="avatar-img"
|
||||
data-type="round"
|
||||
role="presentation"
|
||||
style="--cpd-avatar-size: 36px;"
|
||||
title="@userId:matrix.org"
|
||||
>
|
||||
u
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="mx_EntityTile_details"
|
||||
>
|
||||
<div
|
||||
class="mx_EntityTile_name"
|
||||
>
|
||||
<div
|
||||
class="mx_DisambiguatedProfile"
|
||||
>
|
||||
<span
|
||||
class=""
|
||||
dir="auto"
|
||||
>
|
||||
@userId:matrix.org
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mx_PresenceLabel"
|
||||
>
|
||||
Offline
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
|
@ -0,0 +1,470 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<PinnedEventTile /> should render pinned event 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="mx_PinnedEventTile"
|
||||
role="listitem"
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
class="_avatar_mcap2_17 mx_BaseAvatar mx_PinnedEventTile_senderAvatar _avatar-imageless_mcap2_61"
|
||||
data-color="2"
|
||||
data-testid="avatar-img"
|
||||
data-type="round"
|
||||
role="presentation"
|
||||
style="--cpd-avatar-size: 32px;"
|
||||
>
|
||||
a
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="mx_PinnedEventTile_wrapper"
|
||||
>
|
||||
<div
|
||||
class="mx_PinnedEventTile_top"
|
||||
>
|
||||
<span
|
||||
aria-labelledby="floating-ui-1"
|
||||
class="mx_PinnedEventTile_sender mx_Username_color2"
|
||||
>
|
||||
@alice:server.org
|
||||
</span>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Open menu"
|
||||
class="_icon-button_bh2qc_17"
|
||||
data-state="closed"
|
||||
id="radix-0"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 24px;"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_133tf_26"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 14c-.55 0-1.02-.196-1.412-.588A1.926 1.926 0 0 1 4 12c0-.55.196-1.02.588-1.412A1.926 1.926 0 0 1 6 10c.55 0 1.02.196 1.412.588.392.391.588.862.588 1.412 0 .55-.196 1.02-.588 1.412A1.926 1.926 0 0 1 6 14Zm6 0c-.55 0-1.02-.196-1.412-.588A1.926 1.926 0 0 1 10 12c0-.55.196-1.02.588-1.412A1.926 1.926 0 0 1 12 10c.55 0 1.02.196 1.412.588.392.391.588.862.588 1.412 0 .55-.196 1.02-.588 1.412A1.926 1.926 0 0 1 12 14Zm6 0c-.55 0-1.02-.196-1.413-.588A1.926 1.926 0 0 1 16 12c0-.55.196-1.02.587-1.412A1.926 1.926 0 0 1 18 10c.55 0 1.02.196 1.413.588.391.391.587.862.587 1.412 0 .55-.196 1.02-.587 1.412A1.926 1.926 0 0 1 18 14Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="mx_MTextBody mx_EventTile_content"
|
||||
>
|
||||
<div
|
||||
class="mx_EventTile_body translate"
|
||||
dir="auto"
|
||||
>
|
||||
First pinned message
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<PinnedEventTile /> should render pinned event with thread info 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="mx_PinnedEventTile"
|
||||
role="listitem"
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
class="_avatar_mcap2_17 mx_BaseAvatar mx_PinnedEventTile_senderAvatar _avatar-imageless_mcap2_61"
|
||||
data-color="2"
|
||||
data-testid="avatar-img"
|
||||
data-type="round"
|
||||
role="presentation"
|
||||
style="--cpd-avatar-size: 32px;"
|
||||
>
|
||||
a
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="mx_PinnedEventTile_wrapper"
|
||||
>
|
||||
<div
|
||||
class="mx_PinnedEventTile_top"
|
||||
>
|
||||
<span
|
||||
aria-labelledby="floating-ui-6"
|
||||
class="mx_PinnedEventTile_sender mx_Username_color2"
|
||||
>
|
||||
@alice:server.org
|
||||
</span>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Open menu"
|
||||
class="_icon-button_bh2qc_17"
|
||||
data-state="closed"
|
||||
id="radix-2"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 24px;"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_133tf_26"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 14c-.55 0-1.02-.196-1.412-.588A1.926 1.926 0 0 1 4 12c0-.55.196-1.02.588-1.412A1.926 1.926 0 0 1 6 10c.55 0 1.02.196 1.412.588.392.391.588.862.588 1.412 0 .55-.196 1.02-.588 1.412A1.926 1.926 0 0 1 6 14Zm6 0c-.55 0-1.02-.196-1.412-.588A1.926 1.926 0 0 1 10 12c0-.55.196-1.02.588-1.412A1.926 1.926 0 0 1 12 10c.55 0 1.02.196 1.412.588.392.391.588.862.588 1.412 0 .55-.196 1.02-.588 1.412A1.926 1.926 0 0 1 12 14Zm6 0c-.55 0-1.02-.196-1.413-.588A1.926 1.926 0 0 1 16 12c0-.55.196-1.02.587-1.412A1.926 1.926 0 0 1 18 10c.55 0 1.02.196 1.413.588.391.391.587.862.587 1.412 0 .55-.196 1.02-.587 1.412A1.926 1.926 0 0 1 18 14Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="mx_MTextBody mx_EventTile_content"
|
||||
>
|
||||
<div
|
||||
class="mx_EventTile_body translate"
|
||||
dir="auto"
|
||||
>
|
||||
First pinned message
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mx_PinnedEventTile_thread"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M7 10a.968.968 0 0 1-.713-.287A.968.968 0 0 1 6 9c0-.283.096-.52.287-.713A.968.968 0 0 1 7 8h10a.97.97 0 0 1 .712.287c.192.192.288.43.288.713s-.096.52-.288.713A.968.968 0 0 1 17 10H7Zm0 4a.967.967 0 0 1-.713-.287A.968.968 0 0 1 6 13c0-.283.096-.52.287-.713A.967.967 0 0 1 7 12h6c.283 0 .52.096.713.287.191.192.287.43.287.713s-.096.52-.287.713A.968.968 0 0 1 13 14H7Z"
|
||||
/>
|
||||
<path
|
||||
d="M3.707 21.293c-.63.63-1.707.184-1.707-.707V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6l-2.293 2.293ZM6 17h14V5H4v13.172l.586-.586A2 2 0 0 1 6 17Z"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
Reply to a
|
||||
<button
|
||||
type="button"
|
||||
>
|
||||
thread message
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<PinnedEventTile /> should render the menu with all the options 1`] = `
|
||||
<div
|
||||
aria-label="Open menu"
|
||||
aria-labelledby="radix-8"
|
||||
aria-orientation="vertical"
|
||||
class="_menu_1x5h1_17"
|
||||
data-align="start"
|
||||
data-orientation="vertical"
|
||||
data-radix-menu-content=""
|
||||
data-side="right"
|
||||
data-state="open"
|
||||
dir="ltr"
|
||||
id="radix-9"
|
||||
role="menu"
|
||||
style="outline: none; --radix-dropdown-menu-content-transform-origin: var(--radix-popper-transform-origin); --radix-dropdown-menu-content-available-width: var(--radix-popper-available-width); --radix-dropdown-menu-content-available-height: var(--radix-popper-available-height); --radix-dropdown-menu-trigger-width: var(--radix-popper-anchor-width); --radix-dropdown-menu-trigger-height: var(--radix-popper-anchor-height); pointer-events: auto;"
|
||||
tabindex="-1"
|
||||
>
|
||||
<button
|
||||
class="_item_8j2l6_17 _interactive_8j2l6_35"
|
||||
data-kind="primary"
|
||||
data-orientation="vertical"
|
||||
data-radix-collection-item=""
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="_icon_8j2l6_43"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 16c1.25 0 2.313-.438 3.188-1.313.874-.874 1.312-1.937 1.312-3.187 0-1.25-.438-2.313-1.313-3.188C14.313 7.439 13.25 7 12 7c-1.25 0-2.312.438-3.187 1.313C7.938 9.187 7.5 10.25 7.5 11.5c0 1.25.438 2.313 1.313 3.188C9.688 15.562 10.75 16 12 16Zm0-1.8c-.75 0-1.387-.262-1.912-.787A2.604 2.604 0 0 1 9.3 11.5c0-.75.263-1.387.787-1.912A2.604 2.604 0 0 1 12 8.8c.75 0 1.387.262 1.912.787.525.526.788 1.163.788 1.913s-.262 1.387-.787 1.912A2.604 2.604 0 0 1 12 14.2Zm0 4.8c-2.317 0-4.433-.613-6.35-1.837-1.917-1.226-3.367-2.88-4.35-4.963a.812.812 0 0 1-.1-.313 2.93 2.93 0 0 1 0-.774.812.812 0 0 1 .1-.313c.983-2.083 2.433-3.738 4.35-4.963C7.567 4.614 9.683 4 12 4c2.317 0 4.433.612 6.35 1.838 1.917 1.224 3.367 2.879 4.35 4.962a.81.81 0 0 1 .1.313 2.925 2.925 0 0 1 0 .774.81.81 0 0 1-.1.313c-.983 2.083-2.433 3.738-4.35 4.963C16.433 18.387 14.317 19 12 19Zm0-2a9.544 9.544 0 0 0 5.188-1.488A9.773 9.773 0 0 0 20.8 11.5a9.773 9.773 0 0 0-3.613-4.013A9.544 9.544 0 0 0 12 6a9.545 9.545 0 0 0-5.187 1.487A9.773 9.773 0 0 0 3.2 11.5a9.773 9.773 0 0 0 3.613 4.012A9.544 9.544 0 0 0 12 17Z"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="_typography_yh5dq_162 _font-body-md-medium_yh5dq_69 _label_8j2l6_52"
|
||||
>
|
||||
View in timeline
|
||||
</span>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="_nav-hint_8j2l6_59"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="8 0 8 24"
|
||||
width="8"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8.7 17.3a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7l3.9-3.9-3.9-3.9a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7.948.948 0 0 1 .7-.275.95.95 0 0 1 .7.275l4.6 4.6c.1.1.17.208.213.325.041.117.062.242.062.375s-.02.258-.063.375a.877.877 0 0 1-.212.325l-4.6 4.6a.948.948 0 0 1-.7.275.948.948 0 0 1-.7-.275Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="_item_8j2l6_17 _interactive_8j2l6_35"
|
||||
data-kind="primary"
|
||||
data-orientation="vertical"
|
||||
data-radix-collection-item=""
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="_icon_8j2l6_43"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M5.457 2.083a1 1 0 0 0-1.414 1.414L8.04 7.494v2.25a.5.5 0 0 1-.15.356l-3.7 3.644a.5.5 0 0 0-.15.356v1.4a.5.5 0 0 0 .5.5h6.5v6a1 1 0 0 0 2 0v-6h3.506l4.497 4.497a1 1 0 0 0 1.414-1.414l-17-17ZM14.546 14 10.04 9.494v.25a2.5 2.5 0 0 1-.746 1.781L6.78 14h7.766Z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
<path
|
||||
d="M14.04 4v3.85l2.015 2.015a.5.5 0 0 1-.015-.12V5.257a.5.5 0 0 1 .15-.357l2.081-2.043a.5.5 0 0 0-.35-.857h-9.73l2 2h3.849Z"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="_typography_yh5dq_162 _font-body-md-medium_yh5dq_69 _label_8j2l6_52"
|
||||
>
|
||||
Unpin
|
||||
</span>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="_nav-hint_8j2l6_59"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="8 0 8 24"
|
||||
width="8"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8.7 17.3a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7l3.9-3.9-3.9-3.9a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7.948.948 0 0 1 .7-.275.95.95 0 0 1 .7.275l4.6 4.6c.1.1.17.208.213.325.041.117.062.242.062.375s-.02.258-.063.375a.877.877 0 0 1-.212.325l-4.6 4.6a.948.948 0 0 1-.7.275.948.948 0 0 1-.7-.275Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="_item_8j2l6_17 _interactive_8j2l6_35"
|
||||
data-kind="primary"
|
||||
data-orientation="vertical"
|
||||
data-radix-collection-item=""
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="_icon_8j2l6_43"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M14.597 5.708a1.004 1.004 0 0 1 0-1.416.996.996 0 0 1 1.412 0l4.699 4.714c.39.39.39 1.025 0 1.416l-4.7 4.714a.996.996 0 0 1-1.411 0 1.004 1.004 0 0 1 0-1.416l3.043-3.054H8.487C6.599 10.666 5 12.27 5 14.333 5.002 16.396 6.6 18 8.488 18h2.093a1 1 0 1 1 0 2H8.487C5.42 20 3 17.425 3 14.333c0-3.092 2.42-5.666 5.486-5.666h9.059l-2.95-2.959Z"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="_typography_yh5dq_162 _font-body-md-medium_yh5dq_69 _label_8j2l6_52"
|
||||
>
|
||||
Forward
|
||||
</span>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="_nav-hint_8j2l6_59"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="8 0 8 24"
|
||||
width="8"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8.7 17.3a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7l3.9-3.9-3.9-3.9a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7.948.948 0 0 1 .7-.275.95.95 0 0 1 .7.275l4.6 4.6c.1.1.17.208.213.325.041.117.062.242.062.375s-.02.258-.063.375a.877.877 0 0 1-.212.325l-4.6 4.6a.948.948 0 0 1-.7.275.948.948 0 0 1-.7-.275Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
class="_separator_144s5_17"
|
||||
data-kind="primary"
|
||||
data-orientation="horizontal"
|
||||
role="separator"
|
||||
/>
|
||||
<button
|
||||
class="_item_8j2l6_17 _interactive_8j2l6_35"
|
||||
data-kind="critical"
|
||||
data-orientation="vertical"
|
||||
data-radix-collection-item=""
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="_icon_8j2l6_43"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M7 21c-.55 0-1.02-.196-1.412-.587A1.926 1.926 0 0 1 5 19V6a.968.968 0 0 1-.713-.287A.968.968 0 0 1 4 5c0-.283.096-.52.287-.713A.968.968 0 0 1 5 4h4a.97.97 0 0 1 .287-.712A.968.968 0 0 1 10 3h4a.97.97 0 0 1 .713.288A.968.968 0 0 1 15 4h4a.97.97 0 0 1 .712.287c.192.192.288.43.288.713s-.096.52-.288.713A.968.968 0 0 1 19 6v13c0 .55-.196 1.02-.587 1.413A1.926 1.926 0 0 1 17 21H7ZM7 6v13h10V6H7Zm2 10c0 .283.096.52.287.712.192.192.43.288.713.288s.52-.096.713-.288A.968.968 0 0 0 11 16V9a.967.967 0 0 0-.287-.713A.968.968 0 0 0 10 8a.968.968 0 0 0-.713.287A.968.968 0 0 0 9 9v7Zm4 0c0 .283.096.52.287.712.192.192.43.288.713.288s.52-.096.713-.288A.968.968 0 0 0 15 16V9a.967.967 0 0 0-.287-.713A.968.968 0 0 0 14 8a.968.968 0 0 0-.713.287A.967.967 0 0 0 13 9v7Z"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="_typography_yh5dq_162 _font-body-md-medium_yh5dq_69 _label_8j2l6_52"
|
||||
>
|
||||
Delete
|
||||
</span>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="_nav-hint_8j2l6_59"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="8 0 8 24"
|
||||
width="8"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8.7 17.3a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7l3.9-3.9-3.9-3.9a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7.948.948 0 0 1 .7-.275.95.95 0 0 1 .7.275l4.6 4.6c.1.1.17.208.213.325.041.117.062.242.062.375s-.02.258-.063.375a.877.877 0 0 1-.212.325l-4.6 4.6a.948.948 0 0 1-.7.275.948.948 0 0 1-.7-.275Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<PinnedEventTile /> should render the menu without unpin and delete 1`] = `
|
||||
<div
|
||||
aria-label="Open menu"
|
||||
aria-labelledby="radix-4"
|
||||
aria-orientation="vertical"
|
||||
class="_menu_1x5h1_17"
|
||||
data-align="start"
|
||||
data-orientation="vertical"
|
||||
data-radix-menu-content=""
|
||||
data-side="right"
|
||||
data-state="open"
|
||||
dir="ltr"
|
||||
id="radix-5"
|
||||
role="menu"
|
||||
style="outline: none; --radix-dropdown-menu-content-transform-origin: var(--radix-popper-transform-origin); --radix-dropdown-menu-content-available-width: var(--radix-popper-available-width); --radix-dropdown-menu-content-available-height: var(--radix-popper-available-height); --radix-dropdown-menu-trigger-width: var(--radix-popper-anchor-width); --radix-dropdown-menu-trigger-height: var(--radix-popper-anchor-height); pointer-events: auto;"
|
||||
tabindex="-1"
|
||||
>
|
||||
<button
|
||||
class="_item_8j2l6_17 _interactive_8j2l6_35"
|
||||
data-kind="primary"
|
||||
data-orientation="vertical"
|
||||
data-radix-collection-item=""
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="_icon_8j2l6_43"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 16c1.25 0 2.313-.438 3.188-1.313.874-.874 1.312-1.937 1.312-3.187 0-1.25-.438-2.313-1.313-3.188C14.313 7.439 13.25 7 12 7c-1.25 0-2.312.438-3.187 1.313C7.938 9.187 7.5 10.25 7.5 11.5c0 1.25.438 2.313 1.313 3.188C9.688 15.562 10.75 16 12 16Zm0-1.8c-.75 0-1.387-.262-1.912-.787A2.604 2.604 0 0 1 9.3 11.5c0-.75.263-1.387.787-1.912A2.604 2.604 0 0 1 12 8.8c.75 0 1.387.262 1.912.787.525.526.788 1.163.788 1.913s-.262 1.387-.787 1.912A2.604 2.604 0 0 1 12 14.2Zm0 4.8c-2.317 0-4.433-.613-6.35-1.837-1.917-1.226-3.367-2.88-4.35-4.963a.812.812 0 0 1-.1-.313 2.93 2.93 0 0 1 0-.774.812.812 0 0 1 .1-.313c.983-2.083 2.433-3.738 4.35-4.963C7.567 4.614 9.683 4 12 4c2.317 0 4.433.612 6.35 1.838 1.917 1.224 3.367 2.879 4.35 4.962a.81.81 0 0 1 .1.313 2.925 2.925 0 0 1 0 .774.81.81 0 0 1-.1.313c-.983 2.083-2.433 3.738-4.35 4.963C16.433 18.387 14.317 19 12 19Zm0-2a9.544 9.544 0 0 0 5.188-1.488A9.773 9.773 0 0 0 20.8 11.5a9.773 9.773 0 0 0-3.613-4.013A9.544 9.544 0 0 0 12 6a9.545 9.545 0 0 0-5.187 1.487A9.773 9.773 0 0 0 3.2 11.5a9.773 9.773 0 0 0 3.613 4.012A9.544 9.544 0 0 0 12 17Z"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="_typography_yh5dq_162 _font-body-md-medium_yh5dq_69 _label_8j2l6_52"
|
||||
>
|
||||
View in timeline
|
||||
</span>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="_nav-hint_8j2l6_59"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="8 0 8 24"
|
||||
width="8"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8.7 17.3a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7l3.9-3.9-3.9-3.9a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7.948.948 0 0 1 .7-.275.95.95 0 0 1 .7.275l4.6 4.6c.1.1.17.208.213.325.041.117.062.242.062.375s-.02.258-.063.375a.877.877 0 0 1-.212.325l-4.6 4.6a.948.948 0 0 1-.7.275.948.948 0 0 1-.7-.275Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="_item_8j2l6_17 _interactive_8j2l6_35"
|
||||
data-kind="primary"
|
||||
data-orientation="vertical"
|
||||
data-radix-collection-item=""
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="_icon_8j2l6_43"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M14.597 5.708a1.004 1.004 0 0 1 0-1.416.996.996 0 0 1 1.412 0l4.699 4.714c.39.39.39 1.025 0 1.416l-4.7 4.714a.996.996 0 0 1-1.411 0 1.004 1.004 0 0 1 0-1.416l3.043-3.054H8.487C6.599 10.666 5 12.27 5 14.333 5.002 16.396 6.6 18 8.488 18h2.093a1 1 0 1 1 0 2H8.487C5.42 20 3 17.425 3 14.333c0-3.092 2.42-5.666 5.486-5.666h9.059l-2.95-2.959Z"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="_typography_yh5dq_162 _font-body-md-medium_yh5dq_69 _label_8j2l6_52"
|
||||
>
|
||||
Forward
|
||||
</span>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="_nav-hint_8j2l6_59"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="8 0 8 24"
|
||||
width="8"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8.7 17.3a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7l3.9-3.9-3.9-3.9a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7.948.948 0 0 1 .7-.275.95.95 0 0 1 .7.275l4.6 4.6c.1.1.17.208.213.325.041.117.062.242.062.375s-.02.258-.063.375a.877.877 0 0 1-.212.325l-4.6 4.6a.948.948 0 0 1-.7.275.948.948 0 0 1-.7-.275Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
|
@ -0,0 +1,554 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<PinnedMessageBanner /> should display display a poll event 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
aria-label="This room has pinned messages. Click to view them."
|
||||
class="mx_PinnedMessageBanner"
|
||||
data-single-message="true"
|
||||
data-testid="pinned-message-banner"
|
||||
>
|
||||
<button
|
||||
aria-label="View the pinned message in the timeline."
|
||||
class="mx_PinnedMessageBanner_main"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="mx_PinnedMessageBanner_content"
|
||||
>
|
||||
<div
|
||||
class="mx_PinnedMessageBanner_Indicators"
|
||||
>
|
||||
<div
|
||||
class="mx_PinnedMessageBanner_Indicator mx_PinnedMessageBanner_Indicator--active"
|
||||
data-testid="banner-indicator"
|
||||
/>
|
||||
</div>
|
||||
<svg
|
||||
class="mx_PinnedMessageBanner_PinIcon"
|
||||
fill="currentColor"
|
||||
height="20px"
|
||||
viewBox="0 0 24 24"
|
||||
width="20px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M5.769 2.857A.5.5 0 0 1 6.119 2h11.762a.5.5 0 0 1 .35.857L16.15 4.9a.5.5 0 0 0-.15.357v4.487a.5.5 0 0 0 .15.356l3.7 3.644a.5.5 0 0 1 .15.356v1.4a.5.5 0 0 1-.5.5H13v6a1 1 0 1 1-2 0v-6H4.5a.5.5 0 0 1-.5-.5v-1.4a.5.5 0 0 1 .15-.356l3.7-3.644A.5.5 0 0 0 8 9.744V5.257a.5.5 0 0 0-.15-.357L5.77 2.857Z"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="mx_PinnedMessageBanner_message"
|
||||
data-testid="banner-message"
|
||||
>
|
||||
<span>
|
||||
<span
|
||||
class="mx_PinnedMessageBanner_prefix"
|
||||
>
|
||||
Poll:
|
||||
</span>
|
||||
Alice?
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`<PinnedMessageBanner /> should display the last message when the pinned event array changed 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
aria-label="This room has pinned messages. Click to view them."
|
||||
class="mx_PinnedMessageBanner"
|
||||
data-single-message="false"
|
||||
data-testid="pinned-message-banner"
|
||||
>
|
||||
<button
|
||||
aria-label="View the pinned message in the timeline."
|
||||
class="mx_PinnedMessageBanner_main"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="mx_PinnedMessageBanner_content"
|
||||
>
|
||||
<div
|
||||
class="mx_PinnedMessageBanner_Indicators"
|
||||
>
|
||||
<div
|
||||
class="mx_PinnedMessageBanner_Indicator"
|
||||
data-testid="banner-indicator"
|
||||
/>
|
||||
<div
|
||||
class="mx_PinnedMessageBanner_Indicator"
|
||||
data-testid="banner-indicator"
|
||||
/>
|
||||
<div
|
||||
class="mx_PinnedMessageBanner_Indicator mx_PinnedMessageBanner_Indicator--active"
|
||||
data-testid="banner-indicator"
|
||||
/>
|
||||
</div>
|
||||
<svg
|
||||
class="mx_PinnedMessageBanner_PinIcon"
|
||||
fill="currentColor"
|
||||
height="20px"
|
||||
viewBox="0 0 24 24"
|
||||
width="20px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M5.769 2.857A.5.5 0 0 1 6.119 2h11.762a.5.5 0 0 1 .35.857L16.15 4.9a.5.5 0 0 0-.15.357v4.487a.5.5 0 0 0 .15.356l3.7 3.644a.5.5 0 0 1 .15.356v1.4a.5.5 0 0 1-.5.5H13v6a1 1 0 1 1-2 0v-6H4.5a.5.5 0 0 1-.5-.5v-1.4a.5.5 0 0 1 .15-.356l3.7-3.644A.5.5 0 0 0 8 9.744V5.257a.5.5 0 0 0-.15-.357L5.77 2.857Z"
|
||||
/>
|
||||
</svg>
|
||||
<div
|
||||
class="mx_PinnedMessageBanner_title"
|
||||
data-testid="banner-counter"
|
||||
>
|
||||
<span>
|
||||
<span
|
||||
class="mx_PinnedMessageBanner_title_counter"
|
||||
>
|
||||
3 of 3
|
||||
</span>
|
||||
Pinned messages
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
class="mx_PinnedMessageBanner_message"
|
||||
data-testid="banner-message"
|
||||
>
|
||||
Third pinned message
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
class="_button_i91xf_17 mx_PinnedMessageBanner_actions"
|
||||
data-kind="tertiary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
View all
|
||||
</button>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`<PinnedMessageBanner /> should display the m.audio event type 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
aria-label="This room has pinned messages. Click to view them."
|
||||
class="mx_PinnedMessageBanner"
|
||||
data-single-message="true"
|
||||
data-testid="pinned-message-banner"
|
||||
>
|
||||
<button
|
||||
aria-label="View the pinned message in the timeline."
|
||||
class="mx_PinnedMessageBanner_main"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="mx_PinnedMessageBanner_content"
|
||||
>
|
||||
<div
|
||||
class="mx_PinnedMessageBanner_Indicators"
|
||||
>
|
||||
<div
|
||||
class="mx_PinnedMessageBanner_Indicator mx_PinnedMessageBanner_Indicator--active"
|
||||
data-testid="banner-indicator"
|
||||
/>
|
||||
</div>
|
||||
<svg
|
||||
class="mx_PinnedMessageBanner_PinIcon"
|
||||
fill="currentColor"
|
||||
height="20px"
|
||||
viewBox="0 0 24 24"
|
||||
width="20px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M5.769 2.857A.5.5 0 0 1 6.119 2h11.762a.5.5 0 0 1 .35.857L16.15 4.9a.5.5 0 0 0-.15.357v4.487a.5.5 0 0 0 .15.356l3.7 3.644a.5.5 0 0 1 .15.356v1.4a.5.5 0 0 1-.5.5H13v6a1 1 0 1 1-2 0v-6H4.5a.5.5 0 0 1-.5-.5v-1.4a.5.5 0 0 1 .15-.356l3.7-3.644A.5.5 0 0 0 8 9.744V5.257a.5.5 0 0 0-.15-.357L5.77 2.857Z"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="mx_PinnedMessageBanner_message"
|
||||
data-testid="banner-message"
|
||||
>
|
||||
<span>
|
||||
<span
|
||||
class="mx_PinnedMessageBanner_prefix"
|
||||
>
|
||||
Audio:
|
||||
</span>
|
||||
Message with m.audio type
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`<PinnedMessageBanner /> should display the m.file event type 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
aria-label="This room has pinned messages. Click to view them."
|
||||
class="mx_PinnedMessageBanner"
|
||||
data-single-message="true"
|
||||
data-testid="pinned-message-banner"
|
||||
>
|
||||
<button
|
||||
aria-label="View the pinned message in the timeline."
|
||||
class="mx_PinnedMessageBanner_main"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="mx_PinnedMessageBanner_content"
|
||||
>
|
||||
<div
|
||||
class="mx_PinnedMessageBanner_Indicators"
|
||||
>
|
||||
<div
|
||||
class="mx_PinnedMessageBanner_Indicator mx_PinnedMessageBanner_Indicator--active"
|
||||
data-testid="banner-indicator"
|
||||
/>
|
||||
</div>
|
||||
<svg
|
||||
class="mx_PinnedMessageBanner_PinIcon"
|
||||
fill="currentColor"
|
||||
height="20px"
|
||||
viewBox="0 0 24 24"
|
||||
width="20px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M5.769 2.857A.5.5 0 0 1 6.119 2h11.762a.5.5 0 0 1 .35.857L16.15 4.9a.5.5 0 0 0-.15.357v4.487a.5.5 0 0 0 .15.356l3.7 3.644a.5.5 0 0 1 .15.356v1.4a.5.5 0 0 1-.5.5H13v6a1 1 0 1 1-2 0v-6H4.5a.5.5 0 0 1-.5-.5v-1.4a.5.5 0 0 1 .15-.356l3.7-3.644A.5.5 0 0 0 8 9.744V5.257a.5.5 0 0 0-.15-.357L5.77 2.857Z"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="mx_PinnedMessageBanner_message"
|
||||
data-testid="banner-message"
|
||||
>
|
||||
<span>
|
||||
<span
|
||||
class="mx_PinnedMessageBanner_prefix"
|
||||
>
|
||||
File:
|
||||
</span>
|
||||
Message with m.file type
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`<PinnedMessageBanner /> should display the m.image event type 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
aria-label="This room has pinned messages. Click to view them."
|
||||
class="mx_PinnedMessageBanner"
|
||||
data-single-message="true"
|
||||
data-testid="pinned-message-banner"
|
||||
>
|
||||
<button
|
||||
aria-label="View the pinned message in the timeline."
|
||||
class="mx_PinnedMessageBanner_main"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="mx_PinnedMessageBanner_content"
|
||||
>
|
||||
<div
|
||||
class="mx_PinnedMessageBanner_Indicators"
|
||||
>
|
||||
<div
|
||||
class="mx_PinnedMessageBanner_Indicator mx_PinnedMessageBanner_Indicator--active"
|
||||
data-testid="banner-indicator"
|
||||
/>
|
||||
</div>
|
||||
<svg
|
||||
class="mx_PinnedMessageBanner_PinIcon"
|
||||
fill="currentColor"
|
||||
height="20px"
|
||||
viewBox="0 0 24 24"
|
||||
width="20px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M5.769 2.857A.5.5 0 0 1 6.119 2h11.762a.5.5 0 0 1 .35.857L16.15 4.9a.5.5 0 0 0-.15.357v4.487a.5.5 0 0 0 .15.356l3.7 3.644a.5.5 0 0 1 .15.356v1.4a.5.5 0 0 1-.5.5H13v6a1 1 0 1 1-2 0v-6H4.5a.5.5 0 0 1-.5-.5v-1.4a.5.5 0 0 1 .15-.356l3.7-3.644A.5.5 0 0 0 8 9.744V5.257a.5.5 0 0 0-.15-.357L5.77 2.857Z"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="mx_PinnedMessageBanner_message"
|
||||
data-testid="banner-message"
|
||||
>
|
||||
<span>
|
||||
<span
|
||||
class="mx_PinnedMessageBanner_prefix"
|
||||
>
|
||||
Image:
|
||||
</span>
|
||||
Message with m.image type
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`<PinnedMessageBanner /> should display the m.video event type 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
aria-label="This room has pinned messages. Click to view them."
|
||||
class="mx_PinnedMessageBanner"
|
||||
data-single-message="true"
|
||||
data-testid="pinned-message-banner"
|
||||
>
|
||||
<button
|
||||
aria-label="View the pinned message in the timeline."
|
||||
class="mx_PinnedMessageBanner_main"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="mx_PinnedMessageBanner_content"
|
||||
>
|
||||
<div
|
||||
class="mx_PinnedMessageBanner_Indicators"
|
||||
>
|
||||
<div
|
||||
class="mx_PinnedMessageBanner_Indicator mx_PinnedMessageBanner_Indicator--active"
|
||||
data-testid="banner-indicator"
|
||||
/>
|
||||
</div>
|
||||
<svg
|
||||
class="mx_PinnedMessageBanner_PinIcon"
|
||||
fill="currentColor"
|
||||
height="20px"
|
||||
viewBox="0 0 24 24"
|
||||
width="20px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M5.769 2.857A.5.5 0 0 1 6.119 2h11.762a.5.5 0 0 1 .35.857L16.15 4.9a.5.5 0 0 0-.15.357v4.487a.5.5 0 0 0 .15.356l3.7 3.644a.5.5 0 0 1 .15.356v1.4a.5.5 0 0 1-.5.5H13v6a1 1 0 1 1-2 0v-6H4.5a.5.5 0 0 1-.5-.5v-1.4a.5.5 0 0 1 .15-.356l3.7-3.644A.5.5 0 0 0 8 9.744V5.257a.5.5 0 0 0-.15-.357L5.77 2.857Z"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="mx_PinnedMessageBanner_message"
|
||||
data-testid="banner-message"
|
||||
>
|
||||
<span>
|
||||
<span
|
||||
class="mx_PinnedMessageBanner_prefix"
|
||||
>
|
||||
Video:
|
||||
</span>
|
||||
Message with m.video type
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`<PinnedMessageBanner /> should render 2 pinned event 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
aria-label="This room has pinned messages. Click to view them."
|
||||
class="mx_PinnedMessageBanner"
|
||||
data-single-message="false"
|
||||
data-testid="pinned-message-banner"
|
||||
>
|
||||
<button
|
||||
aria-label="View the pinned message in the timeline."
|
||||
class="mx_PinnedMessageBanner_main"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="mx_PinnedMessageBanner_content"
|
||||
>
|
||||
<div
|
||||
class="mx_PinnedMessageBanner_Indicators"
|
||||
>
|
||||
<div
|
||||
class="mx_PinnedMessageBanner_Indicator"
|
||||
data-testid="banner-indicator"
|
||||
/>
|
||||
<div
|
||||
class="mx_PinnedMessageBanner_Indicator mx_PinnedMessageBanner_Indicator--active"
|
||||
data-testid="banner-indicator"
|
||||
/>
|
||||
</div>
|
||||
<svg
|
||||
class="mx_PinnedMessageBanner_PinIcon"
|
||||
fill="currentColor"
|
||||
height="20px"
|
||||
viewBox="0 0 24 24"
|
||||
width="20px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M5.769 2.857A.5.5 0 0 1 6.119 2h11.762a.5.5 0 0 1 .35.857L16.15 4.9a.5.5 0 0 0-.15.357v4.487a.5.5 0 0 0 .15.356l3.7 3.644a.5.5 0 0 1 .15.356v1.4a.5.5 0 0 1-.5.5H13v6a1 1 0 1 1-2 0v-6H4.5a.5.5 0 0 1-.5-.5v-1.4a.5.5 0 0 1 .15-.356l3.7-3.644A.5.5 0 0 0 8 9.744V5.257a.5.5 0 0 0-.15-.357L5.77 2.857Z"
|
||||
/>
|
||||
</svg>
|
||||
<div
|
||||
class="mx_PinnedMessageBanner_title"
|
||||
data-testid="banner-counter"
|
||||
>
|
||||
<span>
|
||||
<span
|
||||
class="mx_PinnedMessageBanner_title_counter"
|
||||
>
|
||||
2 of 2
|
||||
</span>
|
||||
Pinned messages
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
class="mx_PinnedMessageBanner_message"
|
||||
data-testid="banner-message"
|
||||
>
|
||||
Second pinned message
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
class="_button_i91xf_17 mx_PinnedMessageBanner_actions"
|
||||
data-kind="tertiary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
View all
|
||||
</button>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`<PinnedMessageBanner /> should render 4 pinned event 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
aria-label="This room has pinned messages. Click to view them."
|
||||
class="mx_PinnedMessageBanner"
|
||||
data-single-message="false"
|
||||
data-testid="pinned-message-banner"
|
||||
>
|
||||
<button
|
||||
aria-label="View the pinned message in the timeline."
|
||||
class="mx_PinnedMessageBanner_main"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="mx_PinnedMessageBanner_content"
|
||||
>
|
||||
<div
|
||||
class="mx_PinnedMessageBanner_Indicators"
|
||||
>
|
||||
<div
|
||||
class="mx_PinnedMessageBanner_Indicator mx_PinnedMessageBanner_Indicator--active"
|
||||
data-testid="banner-indicator"
|
||||
/>
|
||||
<div
|
||||
class="mx_PinnedMessageBanner_Indicator mx_PinnedMessageBanner_Indicator--hidden"
|
||||
data-testid="banner-indicator"
|
||||
/>
|
||||
<div
|
||||
class="mx_PinnedMessageBanner_Indicator mx_PinnedMessageBanner_Indicator--hidden"
|
||||
data-testid="banner-indicator"
|
||||
/>
|
||||
</div>
|
||||
<svg
|
||||
class="mx_PinnedMessageBanner_PinIcon"
|
||||
fill="currentColor"
|
||||
height="20px"
|
||||
viewBox="0 0 24 24"
|
||||
width="20px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M5.769 2.857A.5.5 0 0 1 6.119 2h11.762a.5.5 0 0 1 .35.857L16.15 4.9a.5.5 0 0 0-.15.357v4.487a.5.5 0 0 0 .15.356l3.7 3.644a.5.5 0 0 1 .15.356v1.4a.5.5 0 0 1-.5.5H13v6a1 1 0 1 1-2 0v-6H4.5a.5.5 0 0 1-.5-.5v-1.4a.5.5 0 0 1 .15-.356l3.7-3.644A.5.5 0 0 0 8 9.744V5.257a.5.5 0 0 0-.15-.357L5.77 2.857Z"
|
||||
/>
|
||||
</svg>
|
||||
<div
|
||||
class="mx_PinnedMessageBanner_title"
|
||||
data-testid="banner-counter"
|
||||
>
|
||||
<span>
|
||||
<span
|
||||
class="mx_PinnedMessageBanner_title_counter"
|
||||
>
|
||||
4 of 4
|
||||
</span>
|
||||
Pinned messages
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
class="mx_PinnedMessageBanner_message"
|
||||
data-testid="banner-message"
|
||||
>
|
||||
Fourth pinned message
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
class="_button_i91xf_17 mx_PinnedMessageBanner_actions"
|
||||
data-kind="tertiary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
View all
|
||||
</button>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`<PinnedMessageBanner /> should render a single pinned event 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
aria-label="This room has pinned messages. Click to view them."
|
||||
class="mx_PinnedMessageBanner"
|
||||
data-single-message="true"
|
||||
data-testid="pinned-message-banner"
|
||||
>
|
||||
<button
|
||||
aria-label="View the pinned message in the timeline."
|
||||
class="mx_PinnedMessageBanner_main"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="mx_PinnedMessageBanner_content"
|
||||
>
|
||||
<div
|
||||
class="mx_PinnedMessageBanner_Indicators"
|
||||
>
|
||||
<div
|
||||
class="mx_PinnedMessageBanner_Indicator mx_PinnedMessageBanner_Indicator--active"
|
||||
data-testid="banner-indicator"
|
||||
/>
|
||||
</div>
|
||||
<svg
|
||||
class="mx_PinnedMessageBanner_PinIcon"
|
||||
fill="currentColor"
|
||||
height="20px"
|
||||
viewBox="0 0 24 24"
|
||||
width="20px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M5.769 2.857A.5.5 0 0 1 6.119 2h11.762a.5.5 0 0 1 .35.857L16.15 4.9a.5.5 0 0 0-.15.357v4.487a.5.5 0 0 0 .15.356l3.7 3.644a.5.5 0 0 1 .15.356v1.4a.5.5 0 0 1-.5.5H13v6a1 1 0 1 1-2 0v-6H4.5a.5.5 0 0 1-.5-.5v-1.4a.5.5 0 0 1 .15-.356l3.7-3.644A.5.5 0 0 0 8 9.744V5.257a.5.5 0 0 0-.15-.357L5.77 2.857Z"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="mx_PinnedMessageBanner_message"
|
||||
data-testid="banner-message"
|
||||
>
|
||||
First pinned message
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
|
@ -0,0 +1,92 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ReadReceiptGroup <ReadReceiptPerson /> should display a tooltip 1`] = `
|
||||
<div
|
||||
class="_tooltip_1pslb_17"
|
||||
id="floating-ui-6"
|
||||
role="tooltip"
|
||||
style="position: absolute; left: 0px; top: 0px; transform: translate(0px, 0px);"
|
||||
tabindex="-1"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="_arrow_1pslb_42"
|
||||
height="10"
|
||||
style="position: absolute; pointer-events: none; top: 100%;"
|
||||
viewBox="0 0 10 10"
|
||||
width="10"
|
||||
>
|
||||
<path
|
||||
d="M0,0 H10 L5,6 Q5,6 5,6 Z"
|
||||
stroke="none"
|
||||
/>
|
||||
<clippath
|
||||
id="floating-ui-9"
|
||||
>
|
||||
<rect
|
||||
height="10"
|
||||
width="10"
|
||||
x="0"
|
||||
y="0"
|
||||
/>
|
||||
</clippath>
|
||||
</svg>
|
||||
<span
|
||||
id="floating-ui-4"
|
||||
>
|
||||
Alice
|
||||
</span>
|
||||
<span
|
||||
class="_caption_1pslb_37 cpd-theme-dark"
|
||||
id="floating-ui-5"
|
||||
>
|
||||
@alice:example.org
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`ReadReceiptGroup <ReadReceiptPerson /> should render 1`] = `
|
||||
<div>
|
||||
<div>
|
||||
<div
|
||||
class="mx_AccessibleButton mx_ReadReceiptGroup_person"
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
aria-label="Profile picture"
|
||||
aria-live="off"
|
||||
class="_avatar_mcap2_17 mx_BaseAvatar"
|
||||
data-color="3"
|
||||
data-testid="avatar-img"
|
||||
data-type="round"
|
||||
style="--cpd-avatar-size: 24px;"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="_image_mcap2_50"
|
||||
data-type="round"
|
||||
height="24px"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
src="http://this.is.a.url//placekitten.com/400/400"
|
||||
width="24px"
|
||||
/>
|
||||
</span>
|
||||
<div
|
||||
class="mx_ReadReceiptGroup_name"
|
||||
>
|
||||
<p>
|
||||
@alice:example.org
|
||||
</p>
|
||||
<p
|
||||
class="mx_ReadReceiptGroup_secondary"
|
||||
>
|
||||
Wed, 15 May, 0:00
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
|
@ -0,0 +1,152 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`RoomHeader dm does not show the face pile for DMs 1`] = `
|
||||
<DocumentFragment>
|
||||
<header
|
||||
class="mx_Flex mx_RoomHeader light-panel"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x);"
|
||||
>
|
||||
<button
|
||||
aria-label="Open room settings"
|
||||
aria-live="off"
|
||||
class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61"
|
||||
data-color="3"
|
||||
data-testid="avatar-img"
|
||||
data-type="round"
|
||||
role="button"
|
||||
style="--cpd-avatar-size: 40px;"
|
||||
tabindex="-1"
|
||||
>
|
||||
!
|
||||
</button>
|
||||
<button
|
||||
aria-label="Room info"
|
||||
class="mx_RoomHeader_infoWrapper"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="mx_Box mx_RoomHeader_info mx_Box--flex"
|
||||
style="--mx-box-flex: 1;"
|
||||
>
|
||||
<div
|
||||
aria-level="1"
|
||||
class="_typography_yh5dq_162 _font-body-lg-semibold_yh5dq_83 mx_RoomHeader_heading"
|
||||
dir="auto"
|
||||
role="heading"
|
||||
>
|
||||
<span
|
||||
class="mx_RoomHeader_truncated mx_lineClamp"
|
||||
>
|
||||
!1:example.org
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
class="mx_Flex"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x);"
|
||||
>
|
||||
<button
|
||||
aria-labelledby="floating-ui-1180"
|
||||
class="_icon-button_bh2qc_17"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_133tf_26"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
aria-disabled="true"
|
||||
aria-label="There's no one here to call"
|
||||
aria-labelledby="floating-ui-1185"
|
||||
class="_icon-button_bh2qc_17"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_133tf_26"
|
||||
style="--cpd-icon-button-size: 100%; --cpd-color-icon-tertiary: var(--cpd-color-icon-disabled);"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m20.958 16.374.039 3.527c0 .285-.11.537-.33.756-.22.22-.472.33-.756.33a15.97 15.97 0 0 1-6.57-1.105 16.223 16.223 0 0 1-5.563-3.663 16.084 16.084 0 0 1-3.653-5.573 16.313 16.313 0 0 1-1.115-6.56c0-.285.11-.537.33-.757.22-.22.471-.329.755-.329l3.528.039a1.069 1.069 0 0 1 1.085.93l.543 3.954c.026.181.013.349-.039.504a1.088 1.088 0 0 1-.271.426l-1.64 1.64c.337.672.721 1.308 1.154 1.909.433.6 1.444 1.696 1.444 1.696s1.095 1.01 1.696 1.444c.6.433 1.237.817 1.909 1.153l1.64-1.64a1.08 1.08 0 0 1 .426-.27c.155-.052.323-.065.504-.04l3.954.543a1.069 1.069 0 0 1 .93 1.085Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Room info"
|
||||
aria-labelledby="floating-ui-1190"
|
||||
class="_icon-button_bh2qc_17"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_133tf_26"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 17a.97.97 0 0 0 .713-.288A.968.968 0 0 0 13 16v-4a.968.968 0 0 0-.287-.713A.968.968 0 0 0 12 11a.968.968 0 0 0-.713.287A.968.968 0 0 0 11 12v4c0 .283.096.52.287.712.192.192.43.288.713.288Zm0-8c.283 0 .52-.096.713-.287A.967.967 0 0 0 13 8a.967.967 0 0 0-.287-.713A.968.968 0 0 0 12 7a.968.968 0 0 0-.713.287A.967.967 0 0 0 11 8c0 .283.096.52.287.713.192.191.43.287.713.287Zm0 13a9.738 9.738 0 0 1-3.9-.788 10.099 10.099 0 0 1-3.175-2.137c-.9-.9-1.612-1.958-2.137-3.175A9.738 9.738 0 0 1 2 12a9.74 9.74 0 0 1 .788-3.9 10.099 10.099 0 0 1 2.137-3.175c.9-.9 1.958-1.612 3.175-2.137A9.738 9.738 0 0 1 12 2a9.74 9.74 0 0 1 3.9.788 10.098 10.098 0 0 1 3.175 2.137c.9.9 1.613 1.958 2.137 3.175A9.738 9.738 0 0 1 22 12a9.738 9.738 0 0 1-.788 3.9 10.098 10.098 0 0 1-2.137 3.175c-.9.9-1.958 1.613-3.175 2.137A9.738 9.738 0 0 1 12 22Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Threads"
|
||||
aria-labelledby="floating-ui-1195"
|
||||
class="_icon-button_bh2qc_17"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_133tf_26"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M4 3h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6l-2.293 2.293c-.63.63-1.707.184-1.707-.707V5a2 2 0 0 1 2-2Zm3 7h10a.97.97 0 0 0 .712-.287A.967.967 0 0 0 18 9a.967.967 0 0 0-.288-.713A.968.968 0 0 0 17 8H7a.968.968 0 0 0-.713.287A.968.968 0 0 0 6 9c0 .283.096.52.287.713.192.191.43.287.713.287Zm0 4h6c.283 0 .52-.096.713-.287A.968.968 0 0 0 14 13a.968.968 0 0 0-.287-.713A.968.968 0 0 0 13 12H7a.967.967 0 0 0-.713.287A.968.968 0 0 0 6 13c0 .283.096.52.287.713.192.191.43.287.713.287Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
</DocumentFragment>
|
||||
`;
|
|
@ -0,0 +1,476 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<RoomPreviewBar /> message case AskToJoin renders the corresponding actions 1`] = `
|
||||
<div
|
||||
class="mx_RoomPreviewBar_actions mx_RoomPreviewBar_fullWidth"
|
||||
>
|
||||
<div
|
||||
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Request access
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<RoomPreviewBar /> message case AskToJoin renders the corresponding message 1`] = `
|
||||
<div
|
||||
class="mx_RoomPreviewBar_message"
|
||||
>
|
||||
<h3>
|
||||
Ask to join RoomPreviewBar-test-room?
|
||||
</h3>
|
||||
<p>
|
||||
<span
|
||||
class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61"
|
||||
data-color="6"
|
||||
data-testid="avatar-img"
|
||||
data-type="round"
|
||||
role="presentation"
|
||||
style="--cpd-avatar-size: 36px;"
|
||||
>
|
||||
R
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
You need to be granted access to this room in order to view or participate in the conversation. You can send a request to join below.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<RoomPreviewBar /> message case AskToJoin renders the corresponding message when kicked 1`] = `
|
||||
<div
|
||||
class="mx_RoomPreviewBar_message"
|
||||
>
|
||||
<h3>
|
||||
Ask to join RoomPreviewBar-test-room?
|
||||
</h3>
|
||||
<p>
|
||||
<span
|
||||
class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61"
|
||||
data-color="6"
|
||||
data-testid="avatar-img"
|
||||
data-type="round"
|
||||
role="presentation"
|
||||
style="--cpd-avatar-size: 36px;"
|
||||
>
|
||||
R
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
You need to be granted access to this room in order to view or participate in the conversation. You can send a request to join below.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<RoomPreviewBar /> message case AskToJoin renders the corresponding message with a generic title 1`] = `
|
||||
<div
|
||||
class="mx_RoomPreviewBar_message"
|
||||
>
|
||||
<h3>
|
||||
Ask to join?
|
||||
</h3>
|
||||
<p>
|
||||
<span
|
||||
class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61"
|
||||
data-color="1"
|
||||
data-testid="avatar-img"
|
||||
data-type="round"
|
||||
role="presentation"
|
||||
style="--cpd-avatar-size: 36px;"
|
||||
>
|
||||
?
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
You need to be granted access to this room in order to view or participate in the conversation. You can send a request to join below.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<RoomPreviewBar /> message case Knocked renders the corresponding actions 1`] = `
|
||||
<div
|
||||
class="mx_RoomPreviewBar_actions"
|
||||
>
|
||||
<div
|
||||
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_secondary"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Cancel request
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<RoomPreviewBar /> message case Knocked renders the corresponding message 1`] = `
|
||||
<div
|
||||
class="mx_RoomPreviewBar_message"
|
||||
>
|
||||
<h3>
|
||||
Request to join sent
|
||||
</h3>
|
||||
<p>
|
||||
<div
|
||||
class="mx_Icon mx_Icon_16 mx_RoomPreviewBar_icon"
|
||||
/>
|
||||
Your request to join is pending.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<RoomPreviewBar /> renders banned message 1`] = `
|
||||
<div
|
||||
class="mx_RoomPreviewBar_message"
|
||||
>
|
||||
<h3>
|
||||
You were banned from RoomPreviewBar-test-room by @kicker:test.com
|
||||
</h3>
|
||||
<p>
|
||||
Reason: test reason
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<RoomPreviewBar /> renders denied request message 1`] = `
|
||||
<div
|
||||
class="mx_RoomPreviewBar_message"
|
||||
>
|
||||
<h3>
|
||||
You have been denied access
|
||||
</h3>
|
||||
<p>
|
||||
As you have been denied access, you cannot rejoin unless you are invited by the admin or moderator of the group.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<RoomPreviewBar /> renders kicked message 1`] = `
|
||||
<div
|
||||
class="mx_RoomPreviewBar_message"
|
||||
>
|
||||
<h3>
|
||||
You were removed from RoomPreviewBar-test-room by @kicker:test.com
|
||||
</h3>
|
||||
<p>
|
||||
Reason: test reason
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<RoomPreviewBar /> renders viewing room message when room an be previewed 1`] = `
|
||||
<div
|
||||
class="mx_RoomPreviewBar_message"
|
||||
>
|
||||
<h3>
|
||||
You're previewing RoomPreviewBar-test-room. Want to join it?
|
||||
</h3>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<RoomPreviewBar /> renders viewing room message when room can not be previewed 1`] = `
|
||||
<div
|
||||
class="mx_RoomPreviewBar_message"
|
||||
>
|
||||
<h3>
|
||||
RoomPreviewBar-test-room can't be previewed. Do you want to join it?
|
||||
</h3>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<RoomPreviewBar /> with an error renders other errors 1`] = `
|
||||
<div
|
||||
class="mx_RoomPreviewBar_message"
|
||||
>
|
||||
<h3>
|
||||
RoomPreviewBar-test-room is not accessible at this time.
|
||||
</h3>
|
||||
<p>
|
||||
Try again later, or ask a room or space admin to check if you have access.
|
||||
</p>
|
||||
<p>
|
||||
<span>
|
||||
Something_else was returned while trying to access the room or space. If you think you're seeing this message in error, please
|
||||
<a
|
||||
href="https://github.com/vector-im/element-web/issues/new/choose"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
submit a bug report
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<RoomPreviewBar /> with an error renders room not found error 1`] = `
|
||||
<div
|
||||
class="mx_RoomPreviewBar_message"
|
||||
>
|
||||
<h3>
|
||||
RoomPreviewBar-test-room does not exist.
|
||||
</h3>
|
||||
<p>
|
||||
Are you sure you're at the right place?
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<RoomPreviewBar /> with an invite with an invited email when client fails to get 3PIDs renders error message 1`] = `
|
||||
<div
|
||||
class="mx_RoomPreviewBar_message"
|
||||
>
|
||||
<h3>
|
||||
Something went wrong with your invite to RoomPreviewBar-test-room
|
||||
</h3>
|
||||
<p>
|
||||
An error (unknown error code) was returned while trying to validate your invite. You could try to pass this information on to the person who invited you.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<RoomPreviewBar /> with an invite with an invited email when client has an identity server connected renders email mismatch message when invite email mxid doesnt match 1`] = `
|
||||
<div
|
||||
class="mx_RoomPreviewBar_message"
|
||||
>
|
||||
<h3>
|
||||
This invite to RoomPreviewBar-test-room was sent to test@test.com
|
||||
</h3>
|
||||
<p>
|
||||
Share this email in Settings to receive invites directly in Element.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<RoomPreviewBar /> with an invite with an invited email when client has an identity server connected renders invite message when invite email mxid match 1`] = `
|
||||
<div
|
||||
class="mx_RoomPreviewBar_message"
|
||||
>
|
||||
<h3>
|
||||
Do you want to join RoomPreviewBar-test-room?
|
||||
</h3>
|
||||
<p>
|
||||
<span
|
||||
class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61"
|
||||
data-color="6"
|
||||
data-testid="avatar-img"
|
||||
data-type="round"
|
||||
role="presentation"
|
||||
style="--cpd-avatar-size: 36px;"
|
||||
>
|
||||
R
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<span>
|
||||
Invited by
|
||||
<span
|
||||
class="mx_RoomPreviewBar_inviter"
|
||||
>
|
||||
@inviter:test.com
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<RoomPreviewBar /> with an invite with an invited email when client has no identity server connected renders invite message with invited email 1`] = `
|
||||
<div
|
||||
class="mx_RoomPreviewBar_message"
|
||||
>
|
||||
<h3>
|
||||
This invite to RoomPreviewBar-test-room was sent to test@test.com
|
||||
</h3>
|
||||
<p>
|
||||
Use an identity server in Settings to receive invites directly in Element.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<RoomPreviewBar /> with an invite with an invited email when invitedEmail is not associated with current account renders invite message with invited email 1`] = `
|
||||
<div
|
||||
class="mx_RoomPreviewBar_message"
|
||||
>
|
||||
<h3>
|
||||
This invite to RoomPreviewBar-test-room was sent to test@test.com which is not associated with your account
|
||||
</h3>
|
||||
<p>
|
||||
Link this email with your account in Settings to receive invites directly in Element.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<RoomPreviewBar /> with an invite without an invited email for a dm room renders invite message 1`] = `
|
||||
<div
|
||||
class="mx_RoomPreviewBar_message"
|
||||
>
|
||||
<h3>
|
||||
Do you want to chat with @inviter:test.com?
|
||||
</h3>
|
||||
<p>
|
||||
<span
|
||||
class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61"
|
||||
data-color="6"
|
||||
data-testid="avatar-img"
|
||||
data-type="round"
|
||||
role="presentation"
|
||||
style="--cpd-avatar-size: 36px;"
|
||||
>
|
||||
R
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<span>
|
||||
<span
|
||||
class="mx_RoomPreviewBar_inviter"
|
||||
>
|
||||
@inviter:test.com name
|
||||
</span>
|
||||
wants to chat
|
||||
</span>
|
||||
<br />
|
||||
<span
|
||||
class="mx_RoomPreviewBar_inviter_mxid"
|
||||
>
|
||||
@inviter:test.com
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<RoomPreviewBar /> with an invite without an invited email for a dm room renders join and reject action buttons with correct labels 1`] = `
|
||||
<div
|
||||
class="mx_RoomPreviewBar_actions"
|
||||
>
|
||||
<div
|
||||
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Start chatting
|
||||
</div>
|
||||
<div
|
||||
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_secondary"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Reject & Ignore user
|
||||
</div>
|
||||
<div
|
||||
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_secondary"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Reject
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<RoomPreviewBar /> with an invite without an invited email for a non-dm room renders invite message 1`] = `
|
||||
<div
|
||||
class="mx_RoomPreviewBar_message"
|
||||
>
|
||||
<h3>
|
||||
Do you want to join RoomPreviewBar-test-room?
|
||||
</h3>
|
||||
<p>
|
||||
<span
|
||||
class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61"
|
||||
data-color="6"
|
||||
data-testid="avatar-img"
|
||||
data-type="round"
|
||||
role="presentation"
|
||||
style="--cpd-avatar-size: 36px;"
|
||||
>
|
||||
R
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<span>
|
||||
Invited by
|
||||
<span
|
||||
class="mx_RoomPreviewBar_inviter"
|
||||
>
|
||||
@inviter:test.com name
|
||||
</span>
|
||||
</span>
|
||||
<br />
|
||||
<span
|
||||
class="mx_RoomPreviewBar_inviter_mxid"
|
||||
>
|
||||
@inviter:test.com
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<RoomPreviewBar /> with an invite without an invited email for a non-dm room renders join and reject action buttons correctly 1`] = `
|
||||
<div
|
||||
class="mx_RoomPreviewBar_actions"
|
||||
>
|
||||
<div
|
||||
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Accept
|
||||
</div>
|
||||
<div
|
||||
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_secondary"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Reject
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<RoomPreviewBar /> with an invite without an invited email for a non-dm room renders join and reject action buttons in reverse order when room can previewed 1`] = `
|
||||
<div
|
||||
class="mx_RoomPreviewBar_actions"
|
||||
>
|
||||
<div
|
||||
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_secondary"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Reject
|
||||
</div>
|
||||
<div
|
||||
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Accept
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<RoomPreviewBar /> with an invite without an invited email for a non-dm room renders reject and ignore action buttons when handler is provided 1`] = `
|
||||
<div
|
||||
class="mx_RoomPreviewBar_actions"
|
||||
>
|
||||
<div
|
||||
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Accept
|
||||
</div>
|
||||
<div
|
||||
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_secondary"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Reject & Ignore user
|
||||
</div>
|
||||
<div
|
||||
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_secondary"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Reject
|
||||
</div>
|
||||
</div>
|
||||
`;
|
|
@ -0,0 +1,282 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`RoomTile when message previews are enabled and there is a message in a thread should render as expected 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
aria-describedby="mx_RoomTile_messagePreview_!1:example.org"
|
||||
aria-label="!1:example.org"
|
||||
aria-selected="false"
|
||||
class="mx_AccessibleButton mx_RoomTile"
|
||||
role="treeitem"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="mx_DecoratedRoomAvatar"
|
||||
>
|
||||
<span
|
||||
class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61"
|
||||
data-color="3"
|
||||
data-testid="avatar-img"
|
||||
data-type="round"
|
||||
role="presentation"
|
||||
style="--cpd-avatar-size: 32px;"
|
||||
>
|
||||
!
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="mx_RoomTile_titleContainer"
|
||||
>
|
||||
<div
|
||||
class="mx_RoomTile_title mx_RoomTile_titleWithSubtitle"
|
||||
tabindex="-1"
|
||||
title="!1:example.org"
|
||||
>
|
||||
<span
|
||||
dir="auto"
|
||||
>
|
||||
!1:example.org
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="mx_RoomTile_subtitle"
|
||||
id="mx_RoomTile_messagePreview_!1:example.org"
|
||||
title="test thread reply"
|
||||
>
|
||||
<span
|
||||
class="mx_RoomTile_subtitle_text"
|
||||
>
|
||||
test thread reply
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="mx_RoomTile_badgeContainer"
|
||||
/>
|
||||
<div
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-label="Room options"
|
||||
class="mx_AccessibleButton mx_RoomTile_menuButton"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
/>
|
||||
<div
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-label="Notification options"
|
||||
class="mx_AccessibleButton mx_RoomTile_notificationsButton"
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
/>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`RoomTile when message previews are enabled and there is a message in the room should render as expected 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
aria-describedby="mx_RoomTile_messagePreview_!1:example.org"
|
||||
aria-label="!1:example.org Unread messages."
|
||||
aria-selected="false"
|
||||
class="mx_AccessibleButton mx_RoomTile"
|
||||
role="treeitem"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="mx_DecoratedRoomAvatar"
|
||||
>
|
||||
<span
|
||||
class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61"
|
||||
data-color="3"
|
||||
data-testid="avatar-img"
|
||||
data-type="round"
|
||||
role="presentation"
|
||||
style="--cpd-avatar-size: 32px;"
|
||||
>
|
||||
!
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="mx_RoomTile_titleContainer"
|
||||
>
|
||||
<div
|
||||
class="mx_RoomTile_title mx_RoomTile_titleWithSubtitle mx_RoomTile_titleHasUnreadEvents"
|
||||
tabindex="-1"
|
||||
title="!1:example.org"
|
||||
>
|
||||
<span
|
||||
dir="auto"
|
||||
>
|
||||
!1:example.org
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="mx_RoomTile_subtitle"
|
||||
id="mx_RoomTile_messagePreview_!1:example.org"
|
||||
title="test message"
|
||||
>
|
||||
<span
|
||||
class="mx_RoomTile_subtitle_text"
|
||||
>
|
||||
test message
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="mx_RoomTile_badgeContainer"
|
||||
>
|
||||
<div
|
||||
class="mx_NotificationBadge mx_NotificationBadge_visible mx_NotificationBadge_dot"
|
||||
>
|
||||
<span
|
||||
class="mx_NotificationBadge_count"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-label="Room options"
|
||||
class="mx_AccessibleButton mx_RoomTile_menuButton"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
/>
|
||||
<div
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-label="Notification options"
|
||||
class="mx_AccessibleButton mx_RoomTile_notificationsButton"
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
/>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`RoomTile when message previews are enabled should render a room without a message as expected 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
aria-describedby="mx_RoomTile_messagePreview_!1:example.org"
|
||||
aria-label="!1:example.org"
|
||||
aria-selected="false"
|
||||
class="mx_AccessibleButton mx_RoomTile"
|
||||
role="treeitem"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="mx_DecoratedRoomAvatar"
|
||||
>
|
||||
<span
|
||||
class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61"
|
||||
data-color="3"
|
||||
data-testid="avatar-img"
|
||||
data-type="round"
|
||||
role="presentation"
|
||||
style="--cpd-avatar-size: 32px;"
|
||||
>
|
||||
!
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="mx_RoomTile_titleContainer"
|
||||
>
|
||||
<div
|
||||
class="mx_RoomTile_title"
|
||||
tabindex="-1"
|
||||
title="!1:example.org"
|
||||
>
|
||||
<span
|
||||
dir="auto"
|
||||
>
|
||||
!1:example.org
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="mx_RoomTile_badgeContainer"
|
||||
/>
|
||||
<div
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-label="Room options"
|
||||
class="mx_AccessibleButton mx_RoomTile_menuButton"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
/>
|
||||
<div
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-label="Notification options"
|
||||
class="mx_AccessibleButton mx_RoomTile_notificationsButton"
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
/>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`RoomTile when message previews are not enabled should render the room 1`] = `
|
||||
<div>
|
||||
<div
|
||||
aria-label="!1:example.org"
|
||||
aria-selected="false"
|
||||
class="mx_AccessibleButton mx_RoomTile"
|
||||
role="treeitem"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="mx_DecoratedRoomAvatar"
|
||||
>
|
||||
<span
|
||||
class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61"
|
||||
data-color="3"
|
||||
data-testid="avatar-img"
|
||||
data-type="round"
|
||||
role="presentation"
|
||||
style="--cpd-avatar-size: 32px;"
|
||||
>
|
||||
!
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="mx_RoomTile_titleContainer"
|
||||
>
|
||||
<div
|
||||
class="mx_RoomTile_title"
|
||||
tabindex="-1"
|
||||
title="!1:example.org"
|
||||
>
|
||||
<span
|
||||
dir="auto"
|
||||
>
|
||||
!1:example.org
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="mx_RoomTile_badgeContainer"
|
||||
/>
|
||||
<div
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-label="Room options"
|
||||
class="mx_AccessibleButton mx_RoomTile_menuButton"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
/>
|
||||
<div
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-label="Notification options"
|
||||
class="mx_AccessibleButton mx_RoomTile_notificationsButton"
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
|
@ -0,0 +1,149 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<ThirdPartyMemberInfo /> should render invite 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="mx_BaseCard"
|
||||
>
|
||||
<div
|
||||
class="mx_BaseCard_header"
|
||||
>
|
||||
<div
|
||||
class="mx_BaseCard_header_title"
|
||||
>
|
||||
<p
|
||||
class="_typography_yh5dq_162 _font-body-md-medium_yh5dq_69 mx_BaseCard_header_title_heading"
|
||||
role="heading"
|
||||
>
|
||||
Profile
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
aria-labelledby="floating-ui-1"
|
||||
class="_icon-button_bh2qc_17 _subtle-bg_bh2qc_38"
|
||||
data-testid="base-card-close-button"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 28px;"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_133tf_26"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="mx_AutoHideScrollbar"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="mx_Flex mx_ThirdPartyMemberInfo"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-4x);"
|
||||
>
|
||||
<section
|
||||
class="mx_Flex"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x);"
|
||||
>
|
||||
<span
|
||||
class="_typography_yh5dq_162 _font-body-lg-semibold_yh5dq_83"
|
||||
role="heading"
|
||||
>
|
||||
bob@bob.com
|
||||
</span>
|
||||
<span
|
||||
class="_typography_yh5dq_162 _font-body-md-regular_yh5dq_59"
|
||||
>
|
||||
Invited by Alice DisplayName
|
||||
</span>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<ThirdPartyMemberInfo /> should render invite when room in not available 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="mx_BaseCard"
|
||||
>
|
||||
<div
|
||||
class="mx_BaseCard_header"
|
||||
>
|
||||
<div
|
||||
class="mx_BaseCard_header_title"
|
||||
>
|
||||
<p
|
||||
class="_typography_yh5dq_162 _font-body-md-medium_yh5dq_69 mx_BaseCard_header_title_heading"
|
||||
role="heading"
|
||||
>
|
||||
Profile
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
aria-labelledby="floating-ui-6"
|
||||
class="_icon-button_bh2qc_17 _subtle-bg_bh2qc_38"
|
||||
data-testid="base-card-close-button"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 28px;"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_133tf_26"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="mx_AutoHideScrollbar"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="mx_Flex mx_ThirdPartyMemberInfo"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-4x);"
|
||||
>
|
||||
<section
|
||||
class="mx_Flex"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x);"
|
||||
>
|
||||
<span
|
||||
class="_typography_yh5dq_162 _font-body-lg-semibold_yh5dq_83"
|
||||
role="heading"
|
||||
>
|
||||
bob@bob.com
|
||||
</span>
|
||||
<span
|
||||
class="_typography_yh5dq_162 _font-body-md-regular_yh5dq_59"
|
||||
>
|
||||
Invited by Alice DisplayName
|
||||
</span>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
|
@ -0,0 +1,303 @@
|
|||
/*
|
||||
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 "@testing-library/jest-dom";
|
||||
import React from "react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "jest-matrix-react";
|
||||
|
||||
import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext";
|
||||
import RoomContext from "../../../../../../src/contexts/RoomContext";
|
||||
import defaultDispatcher from "../../../../../../src/dispatcher/dispatcher";
|
||||
import { Action } from "../../../../../../src/dispatcher/actions";
|
||||
import { flushPromises, mkEvent } from "../../../../../test-utils";
|
||||
import { EditWysiwygComposer } from "../../../../../../src/components/views/rooms/wysiwyg_composer";
|
||||
import EditorStateTransfer from "../../../../../../src/utils/EditorStateTransfer";
|
||||
import { Emoji } from "../../../../../../src/components/views/rooms/wysiwyg_composer/components/Emoji";
|
||||
import { ChevronFace } from "../../../../../../src/components/structures/ContextMenu";
|
||||
import { ComposerInsertPayload, ComposerType } from "../../../../../../src/dispatcher/payloads/ComposerInsertPayload";
|
||||
import { ActionPayload } from "../../../../../../src/dispatcher/payloads";
|
||||
import * as EmojiButton from "../../../../../../src/components/views/rooms/EmojiButton";
|
||||
import { createMocks } from "./utils";
|
||||
|
||||
describe("EditWysiwygComposer", () => {
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
const { editorStateTransfer, defaultRoomContext, mockClient, mockEvent } = createMocks();
|
||||
|
||||
const customRender = (
|
||||
disabled = false,
|
||||
_editorStateTransfer = editorStateTransfer,
|
||||
client = mockClient,
|
||||
roomContext = defaultRoomContext,
|
||||
) => {
|
||||
return render(
|
||||
<MatrixClientContext.Provider value={client}>
|
||||
<RoomContext.Provider value={roomContext}>
|
||||
<EditWysiwygComposer disabled={disabled} editorStateTransfer={_editorStateTransfer} />
|
||||
</RoomContext.Provider>
|
||||
</MatrixClientContext.Provider>,
|
||||
);
|
||||
};
|
||||
|
||||
beforeAll(
|
||||
async () => {
|
||||
// Load the dynamic import
|
||||
const component = customRender(false);
|
||||
await component.findByRole("textbox");
|
||||
component.unmount();
|
||||
},
|
||||
// it can take a while to load the wasm
|
||||
20000,
|
||||
);
|
||||
|
||||
it("Should not render the component when not ready", async () => {
|
||||
// When
|
||||
const { rerender } = customRender(false);
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
|
||||
|
||||
rerender(
|
||||
<MatrixClientContext.Provider value={mockClient}>
|
||||
<RoomContext.Provider value={{ ...defaultRoomContext, room: undefined }}>
|
||||
<EditWysiwygComposer disabled={false} editorStateTransfer={editorStateTransfer} />
|
||||
</RoomContext.Provider>
|
||||
</MatrixClientContext.Provider>,
|
||||
);
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(screen.queryByRole("textbox")).toBeNull());
|
||||
});
|
||||
|
||||
describe("Initialize with content", () => {
|
||||
it("Should initialize useWysiwyg with html content", async () => {
|
||||
// When
|
||||
customRender(false, editorStateTransfer);
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"), {
|
||||
timeout: 2000,
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("textbox")).toContainHTML(mockEvent.getContent()["formatted_body"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("Should initialize useWysiwyg with plain text content", async () => {
|
||||
// When
|
||||
const mockEvent = mkEvent({
|
||||
type: "m.room.message",
|
||||
room: "myfakeroom",
|
||||
user: "myfakeuser",
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "Replying to this",
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
const editorStateTransfer = new EditorStateTransfer(mockEvent);
|
||||
customRender(false, editorStateTransfer);
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toContainHTML(mockEvent.getContent()["body"]));
|
||||
});
|
||||
|
||||
it("Should ignore when formatted_body is not filled", async () => {
|
||||
// When
|
||||
const mockEvent = mkEvent({
|
||||
type: "m.room.message",
|
||||
room: "myfakeroom",
|
||||
user: "myfakeuser",
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "Replying to this",
|
||||
format: "org.matrix.custom.html",
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
|
||||
const editorStateTransfer = new EditorStateTransfer(mockEvent);
|
||||
customRender(false, editorStateTransfer);
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
|
||||
});
|
||||
|
||||
it("Should strip <mx-reply> tag from initial content", async () => {
|
||||
// When
|
||||
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: "<mx-reply>Reply</mx-reply>My content",
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
|
||||
const editorStateTransfer = new EditorStateTransfer(mockEvent);
|
||||
customRender(false, editorStateTransfer);
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
|
||||
|
||||
// Then
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("textbox")).not.toContainHTML("<mx-reply>Reply</mx-reply>");
|
||||
expect(screen.getByRole("textbox")).toContainHTML("My content");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edit and save actions", () => {
|
||||
let spyDispatcher: jest.SpyInstance<void, [payload: ActionPayload, sync?: boolean]>;
|
||||
beforeEach(async () => {
|
||||
spyDispatcher = jest.spyOn(defaultDispatcher, "dispatch");
|
||||
customRender();
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
spyDispatcher.mockRestore();
|
||||
});
|
||||
|
||||
it("Should cancel edit on cancel button click", async () => {
|
||||
// When
|
||||
screen.getByText("Cancel").click();
|
||||
|
||||
// Then
|
||||
expect(spyDispatcher).toHaveBeenCalledWith({
|
||||
action: Action.EditEvent,
|
||||
event: null,
|
||||
timelineRenderingType: defaultRoomContext.timelineRenderingType,
|
||||
});
|
||||
expect(spyDispatcher).toHaveBeenCalledWith({
|
||||
action: Action.FocusSendMessageComposer,
|
||||
context: defaultRoomContext.timelineRenderingType,
|
||||
});
|
||||
});
|
||||
|
||||
it("Should send message on save button click", async () => {
|
||||
// When
|
||||
fireEvent.input(screen.getByRole("textbox"), {
|
||||
data: "foo bar",
|
||||
inputType: "insertText",
|
||||
});
|
||||
await waitFor(() => expect(screen.getByText("Save")).not.toHaveAttribute("disabled"));
|
||||
|
||||
// Then
|
||||
screen.getByText("Save").click();
|
||||
const expectedContent = {
|
||||
"body": ` * foo bar`,
|
||||
"format": "org.matrix.custom.html",
|
||||
"formatted_body": ` * foo bar`,
|
||||
"m.new_content": {
|
||||
body: "foo bar",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: "foo bar",
|
||||
msgtype: "m.text",
|
||||
},
|
||||
"m.relates_to": {
|
||||
event_id: mockEvent.getId(),
|
||||
rel_type: "m.replace",
|
||||
},
|
||||
"msgtype": "m.text",
|
||||
};
|
||||
await waitFor(() =>
|
||||
expect(mockClient.sendMessage).toHaveBeenCalledWith(mockEvent.getRoomId(), null, expectedContent),
|
||||
);
|
||||
|
||||
expect(spyDispatcher).toHaveBeenCalledWith({ action: "message_sent" });
|
||||
});
|
||||
});
|
||||
|
||||
it("Should focus when receiving an Action.FocusEditMessageComposer action", async () => {
|
||||
// Given we don't have focus
|
||||
customRender();
|
||||
screen.getByLabelText("Bold").focus();
|
||||
expect(screen.getByRole("textbox")).not.toHaveFocus();
|
||||
|
||||
// When we send the right action
|
||||
defaultDispatcher.dispatch({
|
||||
action: Action.FocusEditMessageComposer,
|
||||
context: null,
|
||||
});
|
||||
|
||||
// Then the component gets the focus
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveFocus());
|
||||
});
|
||||
|
||||
it("Should not focus when disabled", async () => {
|
||||
// Given we don't have focus and we are disabled
|
||||
customRender(true);
|
||||
screen.getByLabelText("Bold").focus();
|
||||
expect(screen.getByRole("textbox")).not.toHaveFocus();
|
||||
|
||||
// When we send an action that would cause us to get focus
|
||||
defaultDispatcher.dispatch({
|
||||
action: Action.FocusEditMessageComposer,
|
||||
context: null,
|
||||
});
|
||||
// (Send a second event to exercise the clearTimeout logic)
|
||||
defaultDispatcher.dispatch({
|
||||
action: Action.FocusEditMessageComposer,
|
||||
context: null,
|
||||
});
|
||||
|
||||
// Wait for event dispatch to happen
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
// Then we don't get it because we are disabled
|
||||
expect(screen.getByRole("textbox")).not.toHaveFocus();
|
||||
});
|
||||
|
||||
it("Should add emoji", async () => {
|
||||
// When
|
||||
|
||||
// We are not testing here the emoji button (open modal, select emoji ...)
|
||||
// Instead we are directly firing an emoji to make the test easier to write
|
||||
jest.spyOn(EmojiButton, "EmojiButton").mockImplementation(
|
||||
({ addEmoji }: { addEmoji: (emoji: string) => void }) => {
|
||||
return (
|
||||
<button aria-label="Emoji" type="button" onClick={() => addEmoji("🦫")}>
|
||||
Emoji
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
render(
|
||||
<MatrixClientContext.Provider value={mockClient}>
|
||||
<RoomContext.Provider value={defaultRoomContext}>
|
||||
<EditWysiwygComposer editorStateTransfer={editorStateTransfer} />
|
||||
<Emoji menuPosition={{ chevronFace: ChevronFace.Top }} />
|
||||
</RoomContext.Provider>
|
||||
</MatrixClientContext.Provider>,
|
||||
);
|
||||
// Same behavior as in RoomView.tsx
|
||||
// RoomView is re-dispatching the composer messages.
|
||||
// It adds the composerType fields where the value refers if the composer is in editing or not
|
||||
// The listeners in the RTE ignore the message if the composerType is missing in the payload
|
||||
const dispatcherRef = defaultDispatcher.register((payload: ActionPayload) => {
|
||||
defaultDispatcher.dispatch<ComposerInsertPayload>({
|
||||
...(payload as ComposerInsertPayload),
|
||||
composerType: ComposerType.Edit,
|
||||
});
|
||||
});
|
||||
|
||||
screen.getByLabelText("Emoji").click();
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveTextContent(/🦫/));
|
||||
defaultDispatcher.unregister(dispatcherRef);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,319 @@
|
|||
/*
|
||||
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 "@testing-library/jest-dom";
|
||||
import React from "react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "jest-matrix-react";
|
||||
|
||||
import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext";
|
||||
import RoomContext from "../../../../../../src/contexts/RoomContext";
|
||||
import defaultDispatcher from "../../../../../../src/dispatcher/dispatcher";
|
||||
import { Action } from "../../../../../../src/dispatcher/actions";
|
||||
import { flushPromises } from "../../../../../test-utils";
|
||||
import { SendWysiwygComposer } from "../../../../../../src/components/views/rooms/wysiwyg_composer/";
|
||||
import { aboveLeftOf } from "../../../../../../src/components/structures/ContextMenu";
|
||||
import { ComposerInsertPayload, ComposerType } from "../../../../../../src/dispatcher/payloads/ComposerInsertPayload";
|
||||
import { setSelection } from "../../../../../../src/components/views/rooms/wysiwyg_composer/utils/selection";
|
||||
import { createMocks } from "./utils";
|
||||
|
||||
jest.mock("../../../../../../src/components/views/rooms/EmojiButton", () => ({
|
||||
EmojiButton: ({ addEmoji }: { addEmoji: (emoji: string) => void }) => {
|
||||
return (
|
||||
<button aria-label="Emoji" type="button" onClick={() => addEmoji("🦫")}>
|
||||
Emoji
|
||||
</button>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
describe("SendWysiwygComposer", () => {
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
const { defaultRoomContext, mockClient } = createMocks();
|
||||
|
||||
const registerId = defaultDispatcher.register((payload) => {
|
||||
switch (payload.action) {
|
||||
case Action.ComposerInsert: {
|
||||
if (payload.composerType) break;
|
||||
|
||||
// re-dispatch to the correct composer
|
||||
defaultDispatcher.dispatch<ComposerInsertPayload>({
|
||||
...(payload as ComposerInsertPayload),
|
||||
composerType: ComposerType.Send,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
defaultDispatcher.unregister(registerId);
|
||||
});
|
||||
|
||||
const customRender = (
|
||||
onChange = (_content: string): void => void 0,
|
||||
onSend = (): void => void 0,
|
||||
disabled = false,
|
||||
isRichTextEnabled = true,
|
||||
placeholder?: string,
|
||||
) => {
|
||||
return render(
|
||||
<MatrixClientContext.Provider value={mockClient}>
|
||||
<RoomContext.Provider value={defaultRoomContext}>
|
||||
<SendWysiwygComposer
|
||||
onChange={onChange}
|
||||
onSend={onSend}
|
||||
disabled={disabled}
|
||||
isRichTextEnabled={isRichTextEnabled}
|
||||
menuPosition={aboveLeftOf({ top: 0, bottom: 0, right: 0 })}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</RoomContext.Provider>
|
||||
</MatrixClientContext.Provider>,
|
||||
);
|
||||
};
|
||||
|
||||
it("Should render WysiwygComposer when isRichTextEnabled is at true", async () => {
|
||||
// When
|
||||
customRender(jest.fn(), jest.fn(), false, true);
|
||||
|
||||
// Then
|
||||
expect(await screen.findByTestId("WysiwygComposer", undefined, { timeout: 5000 })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Should render PlainTextComposer when isRichTextEnabled is at false", async () => {
|
||||
// When
|
||||
customRender(jest.fn(), jest.fn(), false, false);
|
||||
|
||||
// Then
|
||||
expect(await screen.findByTestId("PlainTextComposer")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe.each([{ isRichTextEnabled: true }, { isRichTextEnabled: false }])(
|
||||
"Should focus when receiving an Action.FocusSendMessageComposer action",
|
||||
({ isRichTextEnabled }) => {
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it("Should focus when receiving an Action.FocusSendMessageComposer action", async () => {
|
||||
// Given we don't have focus
|
||||
customRender(jest.fn(), jest.fn(), false, isRichTextEnabled);
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
|
||||
|
||||
// When we send the right action
|
||||
defaultDispatcher.dispatch({
|
||||
action: Action.FocusSendMessageComposer,
|
||||
context: null,
|
||||
});
|
||||
|
||||
// Then the component gets the focus
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveFocus());
|
||||
});
|
||||
|
||||
it("Should focus and clear when receiving an Action.ClearAndFocusSendMessageComposer", async () => {
|
||||
// Given we don't have focus
|
||||
const onChange = jest.fn();
|
||||
customRender(onChange, jest.fn(), false, isRichTextEnabled);
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
|
||||
|
||||
fireEvent.input(screen.getByRole("textbox"), {
|
||||
data: "foo bar",
|
||||
inputType: "insertText",
|
||||
});
|
||||
|
||||
// When we send the right action
|
||||
defaultDispatcher.dispatch({
|
||||
action: Action.ClearAndFocusSendMessageComposer,
|
||||
timelineRenderingType: defaultRoomContext.timelineRenderingType,
|
||||
});
|
||||
|
||||
// Then the component gets the focus
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("textbox")).toHaveTextContent(/^$/);
|
||||
expect(screen.getByRole("textbox")).toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
||||
it("Should focus when receiving a reply_to_event action", async () => {
|
||||
// Given we don't have focus
|
||||
customRender(jest.fn(), jest.fn(), false, isRichTextEnabled);
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
|
||||
|
||||
// When we send the right action
|
||||
defaultDispatcher.dispatch({
|
||||
action: "reply_to_event",
|
||||
context: null,
|
||||
});
|
||||
|
||||
// Then the component gets the focus
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveFocus());
|
||||
});
|
||||
|
||||
it("Should not focus when disabled", async () => {
|
||||
// Given we don't have focus and we are disabled
|
||||
customRender(jest.fn(), jest.fn(), true, isRichTextEnabled);
|
||||
expect(screen.getByRole("textbox")).not.toHaveFocus();
|
||||
|
||||
// When we send an action that would cause us to get focus
|
||||
defaultDispatcher.dispatch({
|
||||
action: Action.FocusSendMessageComposer,
|
||||
context: null,
|
||||
});
|
||||
// (Send a second event to exercise the clearTimeout logic)
|
||||
defaultDispatcher.dispatch({
|
||||
action: Action.FocusSendMessageComposer,
|
||||
context: null,
|
||||
});
|
||||
|
||||
// Wait for event dispatch to happen
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
// Then we don't get it because we are disabled
|
||||
expect(screen.getByRole("textbox")).not.toHaveFocus();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe.each([{ isRichTextEnabled: true }, { isRichTextEnabled: false }])(
|
||||
"Placeholder when %s",
|
||||
({ isRichTextEnabled }) => {
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it("Should not has placeholder", async () => {
|
||||
// When
|
||||
customRender(jest.fn(), jest.fn(), false, isRichTextEnabled);
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("textbox")).not.toHaveClass("mx_WysiwygComposer_Editor_content_placeholder");
|
||||
});
|
||||
|
||||
it("Should has placeholder", async () => {
|
||||
// When
|
||||
customRender(jest.fn(), jest.fn(), false, isRichTextEnabled, "my placeholder");
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("textbox")).toHaveClass("mx_WysiwygComposer_Editor_content_placeholder");
|
||||
});
|
||||
|
||||
it("Should display or not placeholder when editor content change", async () => {
|
||||
// When
|
||||
customRender(jest.fn(), jest.fn(), false, isRichTextEnabled, "my placeholder");
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
|
||||
screen.getByRole("textbox").innerHTML = "f";
|
||||
fireEvent.input(screen.getByRole("textbox"), {
|
||||
data: "f",
|
||||
inputType: "insertText",
|
||||
});
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("textbox")).not.toHaveClass(
|
||||
"mx_WysiwygComposer_Editor_content_placeholder",
|
||||
),
|
||||
);
|
||||
|
||||
// When
|
||||
screen.getByRole("textbox").innerHTML = "";
|
||||
fireEvent.input(screen.getByRole("textbox"), {
|
||||
inputType: "deleteContentBackward",
|
||||
});
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("textbox")).toHaveClass("mx_WysiwygComposer_Editor_content_placeholder"),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe.each([{ isRichTextEnabled: true }, { isRichTextEnabled: false }])(
|
||||
"Emoji when %s",
|
||||
({ isRichTextEnabled }) => {
|
||||
let emojiButton: HTMLElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
customRender(jest.fn(), jest.fn(), false, isRichTextEnabled);
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
|
||||
emojiButton = screen.getByLabelText("Emoji");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it("Should add an emoji in an empty composer", async () => {
|
||||
// When
|
||||
emojiButton.click();
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveTextContent(/🦫/));
|
||||
});
|
||||
|
||||
it("Should add an emoji in the middle of a word", async () => {
|
||||
// When
|
||||
screen.getByRole("textbox").focus();
|
||||
screen.getByRole("textbox").innerHTML = "word";
|
||||
fireEvent.input(screen.getByRole("textbox"), {
|
||||
data: "word",
|
||||
inputType: "insertText",
|
||||
});
|
||||
|
||||
const textNode = screen.getByRole("textbox").firstChild;
|
||||
await setSelection({
|
||||
anchorNode: textNode,
|
||||
anchorOffset: 2,
|
||||
focusNode: textNode,
|
||||
focusOffset: 2,
|
||||
isForward: true,
|
||||
});
|
||||
// the event is not automatically fired by jest
|
||||
document.dispatchEvent(new CustomEvent("selectionchange"));
|
||||
|
||||
emojiButton.click();
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveTextContent(/wo🦫rd/));
|
||||
});
|
||||
|
||||
it("Should add an emoji when a word is selected", async () => {
|
||||
// When
|
||||
screen.getByRole("textbox").focus();
|
||||
screen.getByRole("textbox").innerHTML = "word";
|
||||
fireEvent.input(screen.getByRole("textbox"), {
|
||||
data: "word",
|
||||
inputType: "insertText",
|
||||
});
|
||||
|
||||
const textNode = screen.getByRole("textbox").firstChild;
|
||||
await setSelection({
|
||||
anchorNode: textNode,
|
||||
anchorOffset: 3,
|
||||
focusNode: textNode,
|
||||
focusOffset: 2,
|
||||
isForward: false,
|
||||
});
|
||||
// the event is not automatically fired by jest
|
||||
document.dispatchEvent(new CustomEvent("selectionchange"));
|
||||
|
||||
emojiButton.click();
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveTextContent(/wo🦫d/));
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
|
@ -0,0 +1,192 @@
|
|||
/*
|
||||
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 React from "react";
|
||||
import { cleanup, render, screen, waitFor } from "jest-matrix-react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ActionState, ActionTypes, AllActionStates, FormattingFunctions } from "@vector-im/matrix-wysiwyg";
|
||||
|
||||
import { FormattingButtons } from "../../../../../../../src/components/views/rooms/wysiwyg_composer/components/FormattingButtons";
|
||||
import * as LinkModal from "../../../../../../../src/components/views/rooms/wysiwyg_composer/components/LinkModal";
|
||||
import { setLanguage } from "../../../../../../../src/languageHandler";
|
||||
|
||||
const mockWysiwyg = {
|
||||
bold: jest.fn(),
|
||||
italic: jest.fn(),
|
||||
underline: jest.fn(),
|
||||
strikeThrough: jest.fn(),
|
||||
inlineCode: jest.fn(),
|
||||
codeBlock: jest.fn(),
|
||||
link: jest.fn(),
|
||||
orderedList: jest.fn(),
|
||||
unorderedList: jest.fn(),
|
||||
quote: jest.fn(),
|
||||
indent: jest.fn(),
|
||||
unIndent: jest.fn(),
|
||||
} as unknown as FormattingFunctions;
|
||||
|
||||
const openLinkModalSpy = jest.spyOn(LinkModal, "openLinkModal");
|
||||
|
||||
const testCases: Record<
|
||||
Exclude<ActionTypes, "undo" | "redo" | "clear" | "indent" | "unindent">,
|
||||
{ label: string; mockFormatFn: jest.Func | jest.SpyInstance }
|
||||
> = {
|
||||
bold: { label: "Bold", mockFormatFn: mockWysiwyg.bold },
|
||||
italic: { label: "Italic", mockFormatFn: mockWysiwyg.italic },
|
||||
underline: { label: "Underline", mockFormatFn: mockWysiwyg.underline },
|
||||
strikeThrough: { label: "Strikethrough", mockFormatFn: mockWysiwyg.strikeThrough },
|
||||
inlineCode: { label: "Code", mockFormatFn: mockWysiwyg.inlineCode },
|
||||
codeBlock: { label: "Code block", mockFormatFn: mockWysiwyg.inlineCode },
|
||||
link: { label: "Link", mockFormatFn: openLinkModalSpy },
|
||||
orderedList: { label: "Numbered list", mockFormatFn: mockWysiwyg.orderedList },
|
||||
unorderedList: { label: "Bulleted list", mockFormatFn: mockWysiwyg.unorderedList },
|
||||
quote: { label: "Quote", mockFormatFn: mockWysiwyg.quote },
|
||||
};
|
||||
|
||||
const createActionStates = (state: ActionState): AllActionStates => {
|
||||
return Object.fromEntries(Object.keys(testCases).map((testKey) => [testKey, state])) as AllActionStates;
|
||||
};
|
||||
|
||||
const defaultActionStates = createActionStates("enabled");
|
||||
|
||||
const renderComponent = (props = {}) => {
|
||||
return render(<FormattingButtons composer={mockWysiwyg} actionStates={defaultActionStates} {...props} />);
|
||||
};
|
||||
|
||||
const classes = {
|
||||
active: "mx_FormattingButtons_active",
|
||||
hover: "mx_FormattingButtons_Button_hover",
|
||||
disabled: "mx_FormattingButtons_disabled",
|
||||
};
|
||||
|
||||
describe("FormattingButtons", () => {
|
||||
beforeEach(() => {
|
||||
openLinkModalSpy.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it("renders in german", async () => {
|
||||
await setLanguage("de");
|
||||
const { asFragment } = renderComponent();
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
|
||||
await setLanguage("en");
|
||||
});
|
||||
|
||||
it("Each button should not have active class when enabled", () => {
|
||||
renderComponent();
|
||||
|
||||
Object.values(testCases).forEach(({ label }) => {
|
||||
expect(screen.getByLabelText(label)).not.toHaveClass(classes.active);
|
||||
});
|
||||
});
|
||||
|
||||
it("Each button should have active class when reversed", () => {
|
||||
const reversedActionStates = createActionStates("reversed");
|
||||
renderComponent({ actionStates: reversedActionStates });
|
||||
|
||||
Object.values(testCases).forEach((testCase) => {
|
||||
const { label } = testCase;
|
||||
expect(screen.getByLabelText(label)).toHaveClass(classes.active);
|
||||
});
|
||||
});
|
||||
|
||||
it("Each button should have disabled class when disabled", () => {
|
||||
const disabledActionStates = createActionStates("disabled");
|
||||
renderComponent({ actionStates: disabledActionStates });
|
||||
|
||||
Object.values(testCases).forEach((testCase) => {
|
||||
const { label } = testCase;
|
||||
expect(screen.getByLabelText(label)).toHaveClass(classes.disabled);
|
||||
});
|
||||
});
|
||||
|
||||
it("Should call wysiwyg function on button click", async () => {
|
||||
renderComponent();
|
||||
|
||||
for (const testCase of Object.values(testCases)) {
|
||||
const { label, mockFormatFn } = testCase;
|
||||
|
||||
screen.getByLabelText(label).click();
|
||||
expect(mockFormatFn).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("Each button should display the tooltip on mouse over when not disabled", async () => {
|
||||
renderComponent();
|
||||
|
||||
for (const testCase of Object.values(testCases)) {
|
||||
const { label } = testCase;
|
||||
|
||||
await userEvent.hover(screen.getByLabelText(label));
|
||||
await waitFor(() => expect(screen.getByText(label)).toBeInTheDocument());
|
||||
}
|
||||
});
|
||||
|
||||
it("Each button should not display the tooltip on mouse over when disabled", async () => {
|
||||
const disabledActionStates = createActionStates("disabled");
|
||||
renderComponent({ actionStates: disabledActionStates });
|
||||
|
||||
for (const testCase of Object.values(testCases)) {
|
||||
const { label } = testCase;
|
||||
|
||||
await userEvent.hover(screen.getByLabelText(label));
|
||||
expect(screen.queryByText(label)).not.toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("Each button should have hover style when hovered and enabled", async () => {
|
||||
renderComponent();
|
||||
|
||||
for (const testCase of Object.values(testCases)) {
|
||||
const { label } = testCase;
|
||||
|
||||
await userEvent.hover(screen.getByLabelText(label));
|
||||
expect(screen.getByLabelText(label)).toHaveClass("mx_FormattingButtons_Button_hover");
|
||||
}
|
||||
});
|
||||
|
||||
it("Each button should not have hover style when hovered and reversed", async () => {
|
||||
const reversedActionStates = createActionStates("reversed");
|
||||
renderComponent({ actionStates: reversedActionStates });
|
||||
|
||||
for (const testCase of Object.values(testCases)) {
|
||||
const { label } = testCase;
|
||||
|
||||
await userEvent.hover(screen.getByLabelText(label));
|
||||
expect(screen.getByLabelText(label)).not.toHaveClass("mx_FormattingButtons_Button_hover");
|
||||
}
|
||||
});
|
||||
|
||||
it("Does not show indent or unindent button when outside a list", () => {
|
||||
renderComponent();
|
||||
|
||||
expect(screen.queryByLabelText("Indent increase")).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Indent decrease")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Shows indent and unindent buttons when either a single list type is 'reversed'", () => {
|
||||
const orderedListActive = { ...defaultActionStates, orderedList: "reversed" };
|
||||
renderComponent({ actionStates: orderedListActive });
|
||||
|
||||
expect(screen.getByLabelText("Indent increase")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Indent decrease")).toBeInTheDocument();
|
||||
|
||||
cleanup();
|
||||
|
||||
const unorderedListActive = { ...defaultActionStates, unorderedList: "reversed" };
|
||||
|
||||
renderComponent({ actionStates: unorderedListActive });
|
||||
|
||||
expect(screen.getByLabelText("Indent increase")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Indent decrease")).toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,154 @@
|
|||
/*
|
||||
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 { FormattingFunctions } from "@vector-im/matrix-wysiwyg";
|
||||
import { render, screen, waitFor } from "jest-matrix-react";
|
||||
import React from "react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { LinkModal } from "../../../../../../../src/components/views/rooms/wysiwyg_composer/components/LinkModal";
|
||||
import { mockPlatformPeg } from "../../../../../../test-utils";
|
||||
import * as selection from "../../../../../../../src/components/views/rooms/wysiwyg_composer/utils/selection";
|
||||
import { SubSelection } from "../../../../../../../src/components/views/rooms/wysiwyg_composer/types";
|
||||
|
||||
describe("LinkModal", () => {
|
||||
const formattingFunctions = {
|
||||
link: jest.fn(),
|
||||
removeLinks: jest.fn(),
|
||||
getLink: jest.fn().mockReturnValue("my initial content"),
|
||||
} as unknown as FormattingFunctions;
|
||||
const defaultValue: SubSelection = {
|
||||
focusNode: null,
|
||||
anchorNode: null,
|
||||
focusOffset: 3,
|
||||
anchorOffset: 4,
|
||||
isForward: true,
|
||||
};
|
||||
|
||||
const customRender = (isTextEnabled: boolean, onFinished: () => void, isEditing = false) => {
|
||||
return render(
|
||||
<LinkModal
|
||||
composer={formattingFunctions}
|
||||
isTextEnabled={isTextEnabled}
|
||||
onFinished={onFinished}
|
||||
composerContext={{ selection: defaultValue }}
|
||||
isEditing={isEditing}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
const selectionSpy = jest.spyOn(selection, "setSelection");
|
||||
|
||||
beforeEach(() => mockPlatformPeg({ overrideBrowserShortcuts: jest.fn().mockReturnValue(false) }));
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("Should create a link", async () => {
|
||||
// When
|
||||
const onFinished = jest.fn();
|
||||
customRender(false, onFinished);
|
||||
|
||||
// Then
|
||||
expect(screen.getByLabelText("Link")).toBeTruthy();
|
||||
expect(screen.getByText("Save")).toBeDisabled();
|
||||
|
||||
// When
|
||||
await userEvent.type(screen.getByLabelText("Link"), "l");
|
||||
|
||||
// Then
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Save")).toBeEnabled();
|
||||
expect(screen.getByLabelText("Link")).toHaveAttribute("value", "l");
|
||||
});
|
||||
|
||||
// When
|
||||
jest.useFakeTimers();
|
||||
screen.getByText("Save").click();
|
||||
jest.runAllTimers();
|
||||
|
||||
// Then
|
||||
await waitFor(() => {
|
||||
expect(selectionSpy).toHaveBeenCalledWith(defaultValue);
|
||||
expect(onFinished).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(formattingFunctions.link).toHaveBeenCalledWith("l", undefined);
|
||||
});
|
||||
|
||||
it("Should create a link with text", async () => {
|
||||
// When
|
||||
const onFinished = jest.fn();
|
||||
customRender(true, onFinished);
|
||||
|
||||
// Then
|
||||
expect(screen.getByLabelText("Text")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Link")).toBeTruthy();
|
||||
expect(screen.getByText("Save")).toBeDisabled();
|
||||
|
||||
// When
|
||||
await userEvent.type(screen.getByLabelText("Text"), "t");
|
||||
|
||||
// Then
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Save")).toBeDisabled();
|
||||
expect(screen.getByLabelText("Text")).toHaveAttribute("value", "t");
|
||||
});
|
||||
|
||||
// When
|
||||
await userEvent.type(screen.getByLabelText("Link"), "l");
|
||||
|
||||
// Then
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Save")).toBeEnabled();
|
||||
expect(screen.getByLabelText("Link")).toHaveAttribute("value", "l");
|
||||
});
|
||||
|
||||
// When
|
||||
jest.useFakeTimers();
|
||||
screen.getByText("Save").click();
|
||||
jest.runAllTimers();
|
||||
|
||||
// Then
|
||||
await waitFor(() => {
|
||||
expect(selectionSpy).toHaveBeenCalledWith(defaultValue);
|
||||
expect(onFinished).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(formattingFunctions.link).toHaveBeenCalledWith("l", "t");
|
||||
});
|
||||
|
||||
it("Should remove the link", async () => {
|
||||
// When
|
||||
const onFinished = jest.fn();
|
||||
customRender(true, onFinished, true);
|
||||
await userEvent.click(screen.getByText("Remove"));
|
||||
|
||||
// Then
|
||||
expect(formattingFunctions.removeLinks).toHaveBeenCalledTimes(1);
|
||||
expect(onFinished).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("Should display the link in editing", async () => {
|
||||
// When
|
||||
customRender(true, jest.fn(), true);
|
||||
|
||||
// Then
|
||||
expect(screen.getByLabelText("Link")).toContainHTML("my initial content");
|
||||
expect(screen.getByText("Save")).toBeDisabled();
|
||||
|
||||
// When
|
||||
await userEvent.type(screen.getByLabelText("Link"), "l");
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(screen.getByText("Save")).toBeEnabled());
|
||||
});
|
||||
});
|
|
@ -0,0 +1,297 @@
|
|||
/*
|
||||
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 React from "react";
|
||||
import { act, render, screen } from "jest-matrix-react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { PlainTextComposer } from "../../../../../../../src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer";
|
||||
import * as mockUseSettingsHook from "../../../../../../../src/hooks/useSettings";
|
||||
import * as mockKeyboard from "../../../../../../../src/Keyboard";
|
||||
import { createMocks } from "../utils";
|
||||
import RoomContext from "../../../../../../../src/contexts/RoomContext";
|
||||
|
||||
describe("PlainTextComposer", () => {
|
||||
const customRender = (
|
||||
onChange = (_content: string): void => void 0,
|
||||
onSend = (): void => void 0,
|
||||
disabled = false,
|
||||
initialContent?: string,
|
||||
) => {
|
||||
return render(
|
||||
<PlainTextComposer
|
||||
onChange={onChange}
|
||||
onSend={onSend}
|
||||
disabled={disabled}
|
||||
initialContent={initialContent}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
let mockUseSettingValue: jest.SpyInstance;
|
||||
beforeEach(() => {
|
||||
// defaults for these tests are:
|
||||
// ctrlEnterToSend is false
|
||||
mockUseSettingValue = jest.spyOn(mockUseSettingsHook, "useSettingValue").mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("Should have contentEditable at false when disabled", () => {
|
||||
// When
|
||||
customRender(jest.fn(), jest.fn(), true);
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "false");
|
||||
});
|
||||
|
||||
it("Should have focus", () => {
|
||||
// When
|
||||
customRender(jest.fn(), jest.fn(), false);
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("textbox")).toHaveFocus();
|
||||
});
|
||||
|
||||
it("Should call onChange handler", async () => {
|
||||
// When
|
||||
const content = "content";
|
||||
const onChange = jest.fn();
|
||||
customRender(onChange, jest.fn());
|
||||
await userEvent.type(screen.getByRole("textbox"), content);
|
||||
|
||||
// Then
|
||||
expect(onChange).toHaveBeenCalledWith(content);
|
||||
});
|
||||
|
||||
it("Should call onSend when Enter is pressed when ctrlEnterToSend is false", async () => {
|
||||
//When
|
||||
const onSend = jest.fn();
|
||||
customRender(jest.fn(), onSend);
|
||||
await userEvent.type(screen.getByRole("textbox"), "{enter}");
|
||||
|
||||
// Then it sends a message
|
||||
expect(onSend).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("Should not call onSend when Enter is pressed when ctrlEnterToSend is true", async () => {
|
||||
//When
|
||||
mockUseSettingValue.mockReturnValue(true);
|
||||
const onSend = jest.fn();
|
||||
customRender(jest.fn(), onSend);
|
||||
await userEvent.type(screen.getByRole("textbox"), "{enter}");
|
||||
|
||||
// Then it does not send a message
|
||||
expect(onSend).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("Should only call onSend when ctrl+enter is pressed when ctrlEnterToSend is true on windows", async () => {
|
||||
//When
|
||||
mockUseSettingValue.mockReturnValue(true);
|
||||
|
||||
const onSend = jest.fn();
|
||||
customRender(jest.fn(), onSend);
|
||||
const textBox = screen.getByRole("textbox");
|
||||
await userEvent.type(textBox, "hello");
|
||||
|
||||
// Then it does NOT send a message on enter
|
||||
await userEvent.type(textBox, "{enter}");
|
||||
expect(onSend).toHaveBeenCalledTimes(0);
|
||||
|
||||
// Then it does NOT send a message on windows+enter
|
||||
await userEvent.type(textBox, "{meta>}{enter}{meta/}");
|
||||
expect(onSend).toHaveBeenCalledTimes(0);
|
||||
|
||||
// Then it does send a message on ctrl+enter
|
||||
await userEvent.type(textBox, "{control>}{enter}{control/}");
|
||||
expect(onSend).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("Should only call onSend when cmd+enter is pressed when ctrlEnterToSend is true on mac", async () => {
|
||||
//When
|
||||
mockUseSettingValue.mockReturnValue(true);
|
||||
Object.defineProperty(mockKeyboard, "IS_MAC", { value: true });
|
||||
|
||||
const onSend = jest.fn();
|
||||
customRender(jest.fn(), onSend);
|
||||
const textBox = screen.getByRole("textbox");
|
||||
await userEvent.type(textBox, "hello");
|
||||
|
||||
// Then it does NOT send a message on enter
|
||||
await userEvent.type(textBox, "{enter}");
|
||||
expect(onSend).toHaveBeenCalledTimes(0);
|
||||
|
||||
// Then it does NOT send a message on ctrl+enter
|
||||
await userEvent.type(textBox, "{control>}{enter}{control/}");
|
||||
expect(onSend).toHaveBeenCalledTimes(0);
|
||||
|
||||
// Then it does send a message on cmd+enter
|
||||
await userEvent.type(textBox, "{meta>}{enter}{meta/}");
|
||||
expect(onSend).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("Should insert a newline character when shift enter is pressed when ctrlEnterToSend is false", async () => {
|
||||
//When
|
||||
const onSend = jest.fn();
|
||||
customRender(jest.fn(), onSend);
|
||||
const textBox = screen.getByRole("textbox");
|
||||
const inputWithShiftEnter = "new{Shift>}{enter}{/Shift}line";
|
||||
const expectedInnerHtml = "new\nline";
|
||||
|
||||
await userEvent.click(textBox);
|
||||
await userEvent.type(textBox, inputWithShiftEnter);
|
||||
|
||||
// Then it does not send a message, but inserts a newline character
|
||||
expect(onSend).toHaveBeenCalledTimes(0);
|
||||
expect(textBox.innerHTML).toBe(expectedInnerHtml);
|
||||
});
|
||||
|
||||
it("Should insert a newline character when shift enter is pressed when ctrlEnterToSend is true", async () => {
|
||||
//When
|
||||
mockUseSettingValue.mockReturnValue(true);
|
||||
const onSend = jest.fn();
|
||||
customRender(jest.fn(), onSend);
|
||||
const textBox = screen.getByRole("textbox");
|
||||
const keyboardInput = "new{Shift>}{enter}{/Shift}line";
|
||||
const expectedInnerHtml = "new\nline";
|
||||
|
||||
await userEvent.click(textBox);
|
||||
await userEvent.type(textBox, keyboardInput);
|
||||
|
||||
// Then it does not send a message, but inserts a newline character
|
||||
expect(onSend).toHaveBeenCalledTimes(0);
|
||||
expect(textBox.innerHTML).toBe(expectedInnerHtml);
|
||||
});
|
||||
|
||||
it("Should not insert div and br tags when enter is pressed when ctrlEnterToSend is true", async () => {
|
||||
//When
|
||||
mockUseSettingValue.mockReturnValue(true);
|
||||
const onSend = jest.fn();
|
||||
customRender(jest.fn(), onSend);
|
||||
const textBox = screen.getByRole("textbox");
|
||||
const enterThenTypeHtml = "<div>hello</div";
|
||||
|
||||
await userEvent.click(textBox);
|
||||
await userEvent.type(textBox, "{enter}hello");
|
||||
|
||||
// Then it does not send a message, but inserts a newline character
|
||||
expect(onSend).toHaveBeenCalledTimes(0);
|
||||
expect(textBox).not.toContainHTML(enterThenTypeHtml);
|
||||
});
|
||||
|
||||
it("Should not insert div tags when enter is pressed then user types more when ctrlEnterToSend is true", async () => {
|
||||
//When
|
||||
mockUseSettingValue.mockReturnValue(true);
|
||||
const onSend = jest.fn();
|
||||
customRender(jest.fn(), onSend);
|
||||
const textBox = screen.getByRole("textbox");
|
||||
const defaultEnterHtml = "<div><br></div";
|
||||
|
||||
await userEvent.click(textBox);
|
||||
await userEvent.type(textBox, "{enter}");
|
||||
|
||||
// Then it does not send a message, but inserts a newline character
|
||||
expect(onSend).toHaveBeenCalledTimes(0);
|
||||
expect(textBox).not.toContainHTML(defaultEnterHtml);
|
||||
});
|
||||
|
||||
it("Should clear textbox content when clear is called", async () => {
|
||||
//When
|
||||
let composer: {
|
||||
clear: () => void;
|
||||
insertText: (text: string) => void;
|
||||
};
|
||||
|
||||
render(
|
||||
<PlainTextComposer onChange={jest.fn()} onSend={jest.fn()}>
|
||||
{(ref, composerFunctions) => {
|
||||
composer = composerFunctions;
|
||||
return null;
|
||||
}}
|
||||
</PlainTextComposer>,
|
||||
);
|
||||
|
||||
await userEvent.type(screen.getByRole("textbox"), "content");
|
||||
expect(screen.getByRole("textbox").innerHTML).toBe("content");
|
||||
|
||||
composer!.clear();
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("textbox").innerHTML).toBeFalsy();
|
||||
});
|
||||
|
||||
it("Should have data-is-expanded when it has two lines", async () => {
|
||||
let resizeHandler: ResizeObserverCallback = jest.fn();
|
||||
let editor: Element | null = null;
|
||||
jest.spyOn(global, "ResizeObserver").mockImplementation((handler) => {
|
||||
resizeHandler = handler;
|
||||
return {
|
||||
observe: (element) => {
|
||||
editor = element;
|
||||
},
|
||||
unobserve: jest.fn(),
|
||||
disconnect: jest.fn(),
|
||||
};
|
||||
});
|
||||
jest.useFakeTimers();
|
||||
|
||||
//When
|
||||
render(<PlainTextComposer onChange={jest.fn()} onSend={jest.fn()} />);
|
||||
|
||||
// Then
|
||||
expect(screen.getByTestId("WysiwygComposerEditor").dataset["isExpanded"]).toBe("false");
|
||||
expect(editor).toBe(screen.getByRole("textbox"));
|
||||
|
||||
// When
|
||||
resizeHandler(
|
||||
[{ contentBoxSize: [{ blockSize: 100 }] } as unknown as ResizeObserverEntry],
|
||||
{} as ResizeObserver,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(screen.getByTestId("WysiwygComposerEditor").dataset["isExpanded"]).toBe("true");
|
||||
|
||||
jest.useRealTimers();
|
||||
(global.ResizeObserver as jest.Mock).mockRestore();
|
||||
});
|
||||
|
||||
it("Should not render <Autocomplete /> if not wrapped in room context", () => {
|
||||
customRender();
|
||||
expect(screen.queryByTestId("autocomplete-wrapper")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Should render <Autocomplete /> if wrapped in room context", () => {
|
||||
const { defaultRoomContext } = createMocks();
|
||||
|
||||
render(
|
||||
<RoomContext.Provider value={defaultRoomContext}>
|
||||
<PlainTextComposer onChange={jest.fn()} onSend={jest.fn()} disabled={false} initialContent="" />
|
||||
</RoomContext.Provider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("autocomplete-wrapper")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Should allow pasting of text values", async () => {
|
||||
customRender();
|
||||
|
||||
const textBox = screen.getByRole("textbox");
|
||||
|
||||
await userEvent.click(textBox);
|
||||
await userEvent.type(textBox, "hello");
|
||||
await userEvent.paste(" world");
|
||||
|
||||
expect(textBox).toHaveTextContent("hello world");
|
||||
});
|
||||
});
|
|
@ -0,0 +1,124 @@
|
|||
/*
|
||||
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 "@testing-library/jest-dom";
|
||||
import React, { createRef } from "react";
|
||||
import { render, screen, waitFor } from "jest-matrix-react";
|
||||
|
||||
import MatrixClientContext from "../../../../../../../src/contexts/MatrixClientContext";
|
||||
import RoomContext from "../../../../../../../src/contexts/RoomContext";
|
||||
import { WysiwygAutocomplete } from "../../../../../../../src/components/views/rooms/wysiwyg_composer/components/WysiwygAutocomplete";
|
||||
import { getRoomContext, mkStubRoom, stubClient } from "../../../../../../test-utils";
|
||||
import Autocomplete from "../../../../../../../src/components/views/rooms/Autocomplete";
|
||||
import Autocompleter, { ICompletion } from "../../../../../../../src/autocomplete/Autocompleter";
|
||||
import AutocompleteProvider from "../../../../../../../src/autocomplete/AutocompleteProvider";
|
||||
|
||||
const mockCompletion: ICompletion[] = [
|
||||
{
|
||||
type: "user",
|
||||
completion: "user_1",
|
||||
completionId: "@user_1:host.local",
|
||||
range: { start: 1, end: 1 },
|
||||
component: <div>user_1</div>,
|
||||
},
|
||||
{
|
||||
type: "user",
|
||||
completion: "user_2",
|
||||
completionId: "@user_2:host.local",
|
||||
range: { start: 1, end: 1 },
|
||||
component: <div>user_2</div>,
|
||||
},
|
||||
];
|
||||
|
||||
const constructMockProvider = (data: ICompletion[]) =>
|
||||
({
|
||||
getCompletions: jest.fn().mockImplementation(async () => data),
|
||||
getName: jest.fn().mockReturnValue("test provider"),
|
||||
renderCompletions: jest.fn().mockImplementation((components) => components),
|
||||
}) as unknown as AutocompleteProvider;
|
||||
|
||||
describe("WysiwygAutocomplete", () => {
|
||||
beforeAll(() => {
|
||||
// scrollTo not implemented in JSDOM
|
||||
window.HTMLElement.prototype.scrollTo = function () {};
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
const autocompleteRef = createRef<Autocomplete>();
|
||||
const getCompletionsSpy = jest.spyOn(Autocompleter.prototype, "getCompletions").mockResolvedValue([
|
||||
{
|
||||
completions: mockCompletion,
|
||||
provider: constructMockProvider(mockCompletion),
|
||||
command: { command: ["truthy"] as RegExpExecArray }, // needed for us to unhide the autocomplete when testing
|
||||
},
|
||||
]);
|
||||
const mockHandleMention = jest.fn();
|
||||
const mockHandleCommand = jest.fn();
|
||||
const mockHandleAtRoomMention = jest.fn();
|
||||
|
||||
const renderComponent = (props: Partial<React.ComponentProps<typeof WysiwygAutocomplete>> = {}) => {
|
||||
const mockClient = stubClient();
|
||||
const mockRoom = mkStubRoom("test_room", "test_room", mockClient);
|
||||
const mockRoomContext = getRoomContext(mockRoom, {});
|
||||
|
||||
return render(
|
||||
<MatrixClientContext.Provider value={mockClient}>
|
||||
<RoomContext.Provider value={mockRoomContext}>
|
||||
<WysiwygAutocomplete
|
||||
ref={autocompleteRef}
|
||||
suggestion={null}
|
||||
handleMention={mockHandleMention}
|
||||
handleCommand={mockHandleCommand}
|
||||
handleAtRoomMention={mockHandleAtRoomMention}
|
||||
{...props}
|
||||
/>
|
||||
</RoomContext.Provider>
|
||||
</MatrixClientContext.Provider>,
|
||||
);
|
||||
};
|
||||
|
||||
it("does not show the autocomplete when room is undefined", () => {
|
||||
render(
|
||||
<WysiwygAutocomplete
|
||||
ref={autocompleteRef}
|
||||
suggestion={null}
|
||||
handleMention={mockHandleMention}
|
||||
handleCommand={mockHandleCommand}
|
||||
handleAtRoomMention={mockHandleAtRoomMention}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByTestId("autocomplete-wrapper")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not call for suggestions with a null suggestion prop", async () => {
|
||||
// render the component, the default props have suggestion = null
|
||||
renderComponent();
|
||||
|
||||
// check that getCompletions is not called, and we have no suggestions
|
||||
expect(getCompletionsSpy).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("presentation")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls getCompletions when given a valid suggestion prop", async () => {
|
||||
renderComponent({ suggestion: { keyChar: "@", text: "abc", type: "mention" } });
|
||||
|
||||
// wait for getCompletions to have been called
|
||||
await waitFor(() => {
|
||||
expect(getCompletionsSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// check that some suggestions are shown
|
||||
expect(screen.getByRole("presentation")).toBeInTheDocument();
|
||||
|
||||
// and that they are the mock completions
|
||||
mockCompletion.forEach(({ completion }) => expect(screen.getByText(completion)).toBeInTheDocument());
|
||||
});
|
||||
});
|
|
@ -0,0 +1,871 @@
|
|||
/*
|
||||
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 "@testing-library/jest-dom";
|
||||
import React from "react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "jest-matrix-react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { WysiwygComposer } from "../../../../../../../src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer";
|
||||
import SettingsStore from "../../../../../../../src/settings/SettingsStore";
|
||||
import { flushPromises, mockPlatformPeg, stubClient, mkStubRoom } from "../../../../../../test-utils";
|
||||
import defaultDispatcher from "../../../../../../../src/dispatcher/dispatcher";
|
||||
import * as EventUtils from "../../../../../../../src/utils/EventUtils";
|
||||
import { Action } from "../../../../../../../src/dispatcher/actions";
|
||||
import MatrixClientContext from "../../../../../../../src/contexts/MatrixClientContext";
|
||||
import RoomContext from "../../../../../../../src/contexts/RoomContext";
|
||||
import {
|
||||
ComposerContext,
|
||||
getDefaultContextValue,
|
||||
} from "../../../../../../../src/components/views/rooms/wysiwyg_composer/ComposerContext";
|
||||
import { createMocks } from "../utils";
|
||||
import EditorStateTransfer from "../../../../../../../src/utils/EditorStateTransfer";
|
||||
import { SubSelection } from "../../../../../../../src/components/views/rooms/wysiwyg_composer/types";
|
||||
import { setSelection } from "../../../../../../../src/components/views/rooms/wysiwyg_composer/utils/selection";
|
||||
import { parseEditorStateTransfer } from "../../../../../../../src/components/views/rooms/wysiwyg_composer/hooks/useInitialContent";
|
||||
import Autocompleter, { ICompletion } from "../../../../../../../src/autocomplete/Autocompleter";
|
||||
import AutocompleteProvider from "../../../../../../../src/autocomplete/AutocompleteProvider";
|
||||
import * as Permalinks from "../../../../../../../src/utils/permalinks/Permalinks";
|
||||
import { PermalinkParts } from "../../../../../../../src/utils/permalinks/PermalinkConstructor";
|
||||
|
||||
describe("WysiwygComposer", () => {
|
||||
const customRender = (onChange = jest.fn(), onSend = jest.fn(), disabled = false, initialContent?: string) => {
|
||||
const { mockClient, defaultRoomContext } = createMocks();
|
||||
return render(
|
||||
<MatrixClientContext.Provider value={mockClient}>
|
||||
<RoomContext.Provider value={defaultRoomContext}>
|
||||
<WysiwygComposer
|
||||
onChange={onChange}
|
||||
onSend={onSend}
|
||||
disabled={disabled}
|
||||
initialContent={initialContent}
|
||||
/>
|
||||
</RoomContext.Provider>
|
||||
</MatrixClientContext.Provider>,
|
||||
);
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it("Should have contentEditable at false when disabled", async () => {
|
||||
// When
|
||||
customRender(jest.fn(), jest.fn(), true);
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "false"));
|
||||
});
|
||||
|
||||
describe("Standard behavior", () => {
|
||||
const onChange = jest.fn();
|
||||
const onSend = jest.fn();
|
||||
beforeEach(async () => {
|
||||
mockPlatformPeg({ overrideBrowserShortcuts: jest.fn().mockReturnValue(false) });
|
||||
customRender(onChange, onSend);
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
onChange.mockReset();
|
||||
onSend.mockReset();
|
||||
});
|
||||
|
||||
it("Should have contentEditable at true", async () => {
|
||||
// Then
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
|
||||
});
|
||||
|
||||
it("Should have focus", async () => {
|
||||
// Then
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveFocus());
|
||||
});
|
||||
|
||||
it("Should call onChange handler", async () => {
|
||||
// When
|
||||
fireEvent.input(screen.getByRole("textbox"), {
|
||||
data: "foo bar",
|
||||
inputType: "insertText",
|
||||
});
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(onChange).toHaveBeenCalledWith("foo bar"));
|
||||
});
|
||||
|
||||
it("Should call onSend when Enter is pressed", async () => {
|
||||
//When
|
||||
fireEvent(
|
||||
screen.getByRole("textbox"),
|
||||
new InputEvent("input", {
|
||||
inputType: "insertParagraph",
|
||||
}),
|
||||
);
|
||||
|
||||
// Then it sends a message
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("Should not call onSend when Shift+Enter is pressed", async () => {
|
||||
//When
|
||||
await userEvent.type(screen.getByRole("textbox"), "{shift>}{enter}");
|
||||
|
||||
// Then it sends a message
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(0));
|
||||
});
|
||||
|
||||
it("Should not call onSend when ctrl+Enter is pressed", async () => {
|
||||
//When
|
||||
// Using userEvent.type or .keyboard wasn't working as expected in the case of ctrl+enter
|
||||
fireEvent(
|
||||
screen.getByRole("textbox"),
|
||||
new KeyboardEvent("keydown", {
|
||||
ctrlKey: true,
|
||||
code: "Enter",
|
||||
}),
|
||||
);
|
||||
|
||||
// Then it sends a message
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(0));
|
||||
});
|
||||
|
||||
it("Should not call onSend when alt+Enter is pressed", async () => {
|
||||
//When
|
||||
await userEvent.type(screen.getByRole("textbox"), "{alt>}{enter}");
|
||||
|
||||
// Then it sends a message
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(0));
|
||||
});
|
||||
|
||||
it("Should not call onSend when meta+Enter is pressed", async () => {
|
||||
//When
|
||||
await userEvent.type(screen.getByRole("textbox"), "{meta>}{enter}");
|
||||
|
||||
// Then it sends a message
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(0));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mentions and commands", () => {
|
||||
const dispatchSpy = jest.spyOn(defaultDispatcher, "dispatch");
|
||||
|
||||
const mockCompletions: ICompletion[] = [
|
||||
{
|
||||
type: "user",
|
||||
href: "https://matrix.to/#/@user_1:element.io",
|
||||
completion: "user_1",
|
||||
completionId: "@user_1:host.local",
|
||||
range: { start: 1, end: 1 },
|
||||
component: <div>user_1</div>,
|
||||
},
|
||||
{
|
||||
type: "user",
|
||||
href: "https://matrix.to/#/@user_2:element.io",
|
||||
completion: "user_2",
|
||||
completionId: "@user_2:host.local",
|
||||
range: { start: 1, end: 1 },
|
||||
component: <div>user_2</div>,
|
||||
},
|
||||
{
|
||||
// no href user
|
||||
type: "user",
|
||||
href: undefined,
|
||||
completion: "user_without_href",
|
||||
completionId: "@user_3:host.local",
|
||||
range: { start: 1, end: 1 },
|
||||
component: <div>user_without_href</div>,
|
||||
},
|
||||
{
|
||||
type: "room",
|
||||
href: "https://matrix.to/#/#room_1:element.io",
|
||||
completion: "#room_with_completion_id",
|
||||
completionId: "@room_1:host.local",
|
||||
range: { start: 1, end: 1 },
|
||||
component: <div>room_with_completion_id</div>,
|
||||
},
|
||||
{
|
||||
type: "room",
|
||||
href: "https://matrix.to/#/#room_2:element.io",
|
||||
completion: "#room_without_completion_id",
|
||||
range: { start: 1, end: 1 },
|
||||
component: <div>room_without_completion_id</div>,
|
||||
},
|
||||
{
|
||||
type: "command",
|
||||
completion: "/spoiler",
|
||||
range: { start: 1, end: 1 },
|
||||
component: <div>/spoiler</div>,
|
||||
},
|
||||
{
|
||||
type: "at-room",
|
||||
completion: "@room",
|
||||
range: { start: 1, end: 1 },
|
||||
component: <div>@room</div>,
|
||||
},
|
||||
{
|
||||
type: "community",
|
||||
completion: "community-completion",
|
||||
range: { start: 1, end: 1 },
|
||||
component: <div>community</div>,
|
||||
},
|
||||
];
|
||||
|
||||
const constructMockProvider = (data: ICompletion[]) =>
|
||||
({
|
||||
getCompletions: jest.fn().mockImplementation(async () => data),
|
||||
getName: jest.fn().mockReturnValue("test provider"),
|
||||
renderCompletions: jest.fn().mockImplementation((components) => components),
|
||||
}) as unknown as AutocompleteProvider;
|
||||
|
||||
// for each test we will insert input simulating a user mention
|
||||
const initialInput = "@abc";
|
||||
const insertMentionInput = async () => {
|
||||
fireEvent.input(screen.getByRole("textbox"), {
|
||||
data: initialInput,
|
||||
inputType: "insertText",
|
||||
});
|
||||
|
||||
// the autocomplete suggestions container has the presentation role, wait for it to be present
|
||||
expect(await screen.findByRole("presentation")).toBeInTheDocument();
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
// setup the required spies
|
||||
jest.spyOn(Autocompleter.prototype, "getCompletions").mockResolvedValue([
|
||||
{
|
||||
completions: mockCompletions,
|
||||
provider: constructMockProvider(mockCompletions),
|
||||
command: { command: ["truthy"] as RegExpExecArray }, // needed for us to unhide the autocomplete when testing
|
||||
},
|
||||
]);
|
||||
jest.spyOn(Permalinks, "parsePermalink").mockReturnValue({
|
||||
userId: "mockParsedUserId",
|
||||
} as unknown as PermalinkParts);
|
||||
|
||||
// then render the component and wait for the composer to be ready
|
||||
customRender();
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows the autocomplete when text has @ prefix and autoselects the first item", async () => {
|
||||
await insertMentionInput();
|
||||
expect(screen.getByText(mockCompletions[0].completion)).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
it("pressing up and down arrows allows us to change the autocomplete selection", async () => {
|
||||
await insertMentionInput();
|
||||
|
||||
// press the down arrow - nb using .keyboard allows us to not have to specify a node, which
|
||||
// means that we know the autocomplete is correctly catching the event
|
||||
await userEvent.keyboard("{ArrowDown}");
|
||||
expect(screen.getByText(mockCompletions[0].completion)).toHaveAttribute("aria-selected", "false");
|
||||
expect(screen.getByText(mockCompletions[1].completion)).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
// reverse the process and check again
|
||||
await userEvent.keyboard("{ArrowUp}");
|
||||
expect(screen.getByText(mockCompletions[0].completion)).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByText(mockCompletions[1].completion)).toHaveAttribute("aria-selected", "false");
|
||||
});
|
||||
|
||||
it("pressing enter selects the mention and inserts it into the composer as a link", async () => {
|
||||
await insertMentionInput();
|
||||
// press enter
|
||||
await userEvent.keyboard("{Enter}");
|
||||
screen.debug();
|
||||
|
||||
// check that it closes the autocomplete
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("presentation")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// check that it inserts the completion text as a link
|
||||
expect(screen.getByRole("link", { name: mockCompletions[0].completion })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("pressing escape closes the autocomplete", async () => {
|
||||
await insertMentionInput();
|
||||
|
||||
// press escape
|
||||
await userEvent.keyboard("{Escape}");
|
||||
|
||||
// check that it closes the autocomplete
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("presentation")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("typing with the autocomplete open still works as expected", async () => {
|
||||
await insertMentionInput();
|
||||
|
||||
// add some more text, then check the autocomplete is open AND the text is in the composer
|
||||
await userEvent.keyboard("extra");
|
||||
|
||||
expect(screen.queryByRole("presentation")).toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox")).toHaveTextContent("@abcextra");
|
||||
});
|
||||
|
||||
it("clicking on a mention in the composer dispatches the correct action", async () => {
|
||||
await insertMentionInput();
|
||||
|
||||
// press enter
|
||||
await userEvent.keyboard("{Enter}");
|
||||
|
||||
// check that it closes the autocomplete
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("presentation")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// click on the user mention link that has been inserted
|
||||
await userEvent.click(screen.getByRole("link", { name: mockCompletions[0].completion }));
|
||||
expect(dispatchSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// this relies on the output from the mock function in mkStubRoom
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: Action.ViewUser,
|
||||
member: expect.objectContaining({
|
||||
userId: mkStubRoom(undefined, undefined, undefined).getMember("any")?.userId,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("selecting a mention without a href closes the autocomplete but does not insert a mention", async () => {
|
||||
await insertMentionInput();
|
||||
|
||||
// select the relevant user by clicking
|
||||
await userEvent.click(screen.getByText("user_without_href"));
|
||||
|
||||
// check that it closes the autocomplete
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("presentation")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// check that it has not inserted a link
|
||||
expect(screen.queryByRole("link", { name: "user_without_href" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("selecting a room mention with a completionId uses client.getRoom", async () => {
|
||||
await insertMentionInput();
|
||||
|
||||
// select the room suggestion by clicking
|
||||
await userEvent.click(screen.getByText("room_with_completion_id"));
|
||||
|
||||
// check that it closes the autocomplete
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("presentation")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// check that it has inserted a link and looked up the name from the mock client
|
||||
// which will always return 'My room'
|
||||
expect(screen.getByRole("link", { name: "My room" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("selecting a room mention without a completionId uses client.getRooms", async () => {
|
||||
await insertMentionInput();
|
||||
|
||||
// select the room suggestion
|
||||
await userEvent.click(screen.getByText("room_without_completion_id"));
|
||||
|
||||
// check that it closes the autocomplete
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("presentation")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// check that it has inserted a link and falls back to the completion text
|
||||
expect(screen.getByRole("link", { name: "#room_without_completion_id" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("selecting a command inserts the command", async () => {
|
||||
await insertMentionInput();
|
||||
|
||||
// select the room suggestion
|
||||
await userEvent.click(screen.getByText("/spoiler"));
|
||||
|
||||
// check that it has inserted the plain text
|
||||
expect(screen.getByText("/spoiler")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("selecting an at-room completion inserts @room", async () => {
|
||||
await insertMentionInput();
|
||||
|
||||
// select the room suggestion
|
||||
await userEvent.click(screen.getByText("@room"));
|
||||
|
||||
// check that it has inserted the @room link
|
||||
expect(screen.getByRole("link", { name: "@room" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("allows a community completion to pass through", async () => {
|
||||
await insertMentionInput();
|
||||
|
||||
// select the room suggestion
|
||||
await userEvent.click(screen.getByText("community"));
|
||||
|
||||
// check that it we still have the initial text
|
||||
expect(screen.getByText(initialInput)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("When emoticons should be replaced by emojis", () => {
|
||||
const onChange = jest.fn();
|
||||
const onSend = jest.fn();
|
||||
beforeEach(async () => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string) => {
|
||||
if (name === "MessageComposerInput.autoReplaceEmoji") return true;
|
||||
});
|
||||
customRender(onChange, onSend);
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
|
||||
});
|
||||
it("typing a space to trigger an emoji replacement", async () => {
|
||||
fireEvent.input(screen.getByRole("textbox"), {
|
||||
data: ":P",
|
||||
inputType: "insertText",
|
||||
});
|
||||
fireEvent.input(screen.getByRole("textbox"), {
|
||||
data: " ",
|
||||
inputType: "insertText",
|
||||
});
|
||||
|
||||
await waitFor(() => expect(onChange).toHaveBeenNthCalledWith(3, expect.stringContaining("😛")));
|
||||
});
|
||||
it("typing a space to trigger an emoji varitation replacement", async () => {
|
||||
fireEvent.input(screen.getByRole("textbox"), {
|
||||
data: ":-P",
|
||||
inputType: "insertText",
|
||||
});
|
||||
fireEvent.input(screen.getByRole("textbox"), {
|
||||
data: " ",
|
||||
inputType: "insertText",
|
||||
});
|
||||
|
||||
await waitFor(() => expect(onChange).toHaveBeenNthCalledWith(3, expect.stringContaining("😛")));
|
||||
});
|
||||
});
|
||||
|
||||
describe("When settings require Ctrl+Enter to send", () => {
|
||||
const onChange = jest.fn();
|
||||
const onSend = jest.fn();
|
||||
beforeEach(async () => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string) => {
|
||||
if (name === "MessageComposerInput.ctrlEnterToSend") return true;
|
||||
});
|
||||
customRender(onChange, onSend);
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
onChange.mockReset();
|
||||
onSend.mockReset();
|
||||
});
|
||||
|
||||
it("Should not call onSend when Enter is pressed", async () => {
|
||||
// When
|
||||
const textbox = screen.getByRole("textbox");
|
||||
|
||||
fireEvent(
|
||||
textbox,
|
||||
new InputEvent("input", {
|
||||
inputType: "insertParagraph",
|
||||
}),
|
||||
);
|
||||
|
||||
// Then it does not send a message
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(0));
|
||||
|
||||
fireEvent(
|
||||
textbox,
|
||||
new InputEvent("input", {
|
||||
inputType: "insertText",
|
||||
data: "other",
|
||||
}),
|
||||
);
|
||||
|
||||
// The focus is on the last text node
|
||||
await waitFor(() => {
|
||||
const selection = document.getSelection();
|
||||
if (selection) {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect(selection.focusNode?.textContent).toEqual("other");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("Should send a message when Ctrl+Enter is pressed", async () => {
|
||||
// When
|
||||
fireEvent(
|
||||
screen.getByRole("textbox"),
|
||||
new InputEvent("input", {
|
||||
inputType: "sendMessage",
|
||||
}),
|
||||
);
|
||||
|
||||
// Then it sends a message
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Keyboard navigation", () => {
|
||||
const { mockClient, defaultRoomContext, mockEvent, editorStateTransfer } = createMocks();
|
||||
|
||||
const customRender = (
|
||||
client = mockClient,
|
||||
roomContext = defaultRoomContext,
|
||||
_editorStateTransfer?: EditorStateTransfer,
|
||||
) => {
|
||||
return render(
|
||||
<MatrixClientContext.Provider value={client}>
|
||||
<RoomContext.Provider value={roomContext}>
|
||||
<ComposerContext.Provider
|
||||
value={getDefaultContextValue({ editorStateTransfer: _editorStateTransfer })}
|
||||
>
|
||||
<WysiwygComposer
|
||||
onChange={jest.fn()}
|
||||
onSend={jest.fn()}
|
||||
initialContent={
|
||||
roomContext.room && _editorStateTransfer
|
||||
? parseEditorStateTransfer(_editorStateTransfer, roomContext.room, client)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</ComposerContext.Provider>
|
||||
</RoomContext.Provider>
|
||||
</MatrixClientContext.Provider>,
|
||||
);
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
const setup = async (
|
||||
editorState?: EditorStateTransfer,
|
||||
client = stubClient(),
|
||||
roomContext = defaultRoomContext,
|
||||
) => {
|
||||
const spyDispatcher = jest.spyOn(defaultDispatcher, "dispatch");
|
||||
|
||||
customRender(client, roomContext, editorState);
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
|
||||
return { textbox: screen.getByRole("textbox"), spyDispatcher };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockPlatformPeg({ overrideBrowserShortcuts: jest.fn().mockReturnValue(false) });
|
||||
jest.spyOn(EventUtils, "findEditableEvent").mockReturnValue(mockEvent);
|
||||
});
|
||||
|
||||
describe("In message creation", () => {
|
||||
it("Should not moving when the composer is filled", async () => {
|
||||
// When
|
||||
const { textbox, spyDispatcher } = await setup();
|
||||
fireEvent.input(textbox, {
|
||||
data: "word",
|
||||
inputType: "insertText",
|
||||
});
|
||||
|
||||
// Move at the beginning of the composer
|
||||
fireEvent.keyDown(textbox, {
|
||||
key: "ArrowUp",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(spyDispatcher).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("Should moving when the composer is empty", async () => {
|
||||
// When
|
||||
const { textbox, spyDispatcher } = await setup();
|
||||
|
||||
fireEvent.keyDown(textbox, {
|
||||
key: "ArrowUp",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(spyDispatcher).toHaveBeenCalledWith({
|
||||
action: Action.EditEvent,
|
||||
event: mockEvent,
|
||||
timelineRenderingType: defaultRoomContext.timelineRenderingType,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("In message editing", () => {
|
||||
function select(selection: SubSelection) {
|
||||
return act(async () => {
|
||||
await setSelection(selection);
|
||||
// the event is not automatically fired by jest
|
||||
document.dispatchEvent(new CustomEvent("selectionchange"));
|
||||
});
|
||||
}
|
||||
|
||||
describe("Moving up", () => {
|
||||
it("Should not moving when caret is not at beginning of the text", async () => {
|
||||
// When
|
||||
const { textbox, spyDispatcher } = await setup(editorStateTransfer);
|
||||
const textNode = textbox.firstChild;
|
||||
await select({
|
||||
anchorNode: textNode,
|
||||
anchorOffset: 1,
|
||||
focusNode: textNode,
|
||||
focusOffset: 2,
|
||||
isForward: true,
|
||||
});
|
||||
|
||||
fireEvent.keyDown(textbox, {
|
||||
key: "ArrowUp",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(spyDispatcher).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("Should not moving when the content has changed", async () => {
|
||||
// When
|
||||
const { textbox, spyDispatcher } = await setup(editorStateTransfer);
|
||||
fireEvent.input(textbox, {
|
||||
data: "word",
|
||||
inputType: "insertText",
|
||||
});
|
||||
const textNode = textbox.firstChild;
|
||||
await select({
|
||||
anchorNode: textNode,
|
||||
anchorOffset: 0,
|
||||
focusNode: textNode,
|
||||
focusOffset: 0,
|
||||
isForward: true,
|
||||
});
|
||||
|
||||
fireEvent.keyDown(textbox, {
|
||||
key: "ArrowUp",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(spyDispatcher).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("Should moving up", async () => {
|
||||
// When
|
||||
const { textbox, spyDispatcher } = await setup(editorStateTransfer);
|
||||
const textNode = textbox.firstChild;
|
||||
await select({
|
||||
anchorNode: textNode,
|
||||
anchorOffset: 0,
|
||||
focusNode: textNode,
|
||||
focusOffset: 0,
|
||||
isForward: true,
|
||||
});
|
||||
|
||||
fireEvent.keyDown(textbox, {
|
||||
key: "ArrowUp",
|
||||
});
|
||||
|
||||
// Wait for event dispatch to happen
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(spyDispatcher).toHaveBeenCalledWith({
|
||||
action: Action.EditEvent,
|
||||
event: mockEvent,
|
||||
timelineRenderingType: defaultRoomContext.timelineRenderingType,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("Should moving up in list", async () => {
|
||||
// When
|
||||
const { mockEvent, defaultRoomContext, mockClient, editorStateTransfer } = createMocks(
|
||||
"<ul><li><strong>Content</strong></li><li>Other Content</li></ul>",
|
||||
);
|
||||
jest.spyOn(EventUtils, "findEditableEvent").mockReturnValue(mockEvent);
|
||||
const { textbox, spyDispatcher } = await setup(editorStateTransfer, mockClient, defaultRoomContext);
|
||||
|
||||
const textNode = textbox.firstChild;
|
||||
await select({
|
||||
anchorNode: textNode,
|
||||
anchorOffset: 0,
|
||||
focusNode: textNode,
|
||||
focusOffset: 0,
|
||||
isForward: true,
|
||||
});
|
||||
|
||||
fireEvent.keyDown(textbox, {
|
||||
key: "ArrowUp",
|
||||
});
|
||||
|
||||
// Wait for event dispatch to happen
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(spyDispatcher).toHaveBeenCalledWith({
|
||||
action: Action.EditEvent,
|
||||
event: mockEvent,
|
||||
timelineRenderingType: defaultRoomContext.timelineRenderingType,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Moving down", () => {
|
||||
it("Should not moving when caret is not at the end of the text", async () => {
|
||||
// When
|
||||
const { textbox, spyDispatcher } = await setup(editorStateTransfer);
|
||||
const brNode = textbox.lastChild;
|
||||
await select({
|
||||
anchorNode: brNode,
|
||||
anchorOffset: 0,
|
||||
focusNode: brNode,
|
||||
focusOffset: 0,
|
||||
isForward: true,
|
||||
});
|
||||
|
||||
fireEvent.keyDown(textbox, {
|
||||
key: "ArrowDown",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(spyDispatcher).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("Should not moving when the content has changed", async () => {
|
||||
// When
|
||||
const { textbox, spyDispatcher } = await setup(editorStateTransfer);
|
||||
fireEvent.input(textbox, {
|
||||
data: "word",
|
||||
inputType: "insertText",
|
||||
});
|
||||
const brNode = textbox.lastChild;
|
||||
await select({
|
||||
anchorNode: brNode,
|
||||
anchorOffset: 0,
|
||||
focusNode: brNode,
|
||||
focusOffset: 0,
|
||||
isForward: true,
|
||||
});
|
||||
|
||||
fireEvent.keyDown(textbox, {
|
||||
key: "ArrowDown",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(spyDispatcher).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("Should moving down", async () => {
|
||||
// When
|
||||
const { textbox, spyDispatcher } = await setup(editorStateTransfer);
|
||||
// Skipping the BR tag
|
||||
const textNode = textbox.childNodes[textbox.childNodes.length - 2];
|
||||
const { length } = textNode.textContent || "";
|
||||
await select({
|
||||
anchorNode: textNode,
|
||||
anchorOffset: length,
|
||||
focusNode: textNode,
|
||||
focusOffset: length,
|
||||
isForward: true,
|
||||
});
|
||||
|
||||
fireEvent.keyDown(textbox, {
|
||||
key: "ArrowDown",
|
||||
});
|
||||
|
||||
// Wait for event dispatch to happen
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(spyDispatcher).toHaveBeenCalledWith({
|
||||
action: Action.EditEvent,
|
||||
event: mockEvent,
|
||||
timelineRenderingType: defaultRoomContext.timelineRenderingType,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("Should moving down in list", async () => {
|
||||
// When
|
||||
const { mockEvent, defaultRoomContext, mockClient, editorStateTransfer } = createMocks(
|
||||
"<ul><li><strong>Content</strong></li><li>Other Content</li></ul>",
|
||||
);
|
||||
jest.spyOn(EventUtils, "findEditableEvent").mockReturnValue(mockEvent);
|
||||
const { textbox, spyDispatcher } = await setup(editorStateTransfer, mockClient, defaultRoomContext);
|
||||
|
||||
// Skipping the BR tag and get the text node inside the last LI tag
|
||||
const textNode = textbox.childNodes[textbox.childNodes.length - 2].lastChild?.lastChild || textbox;
|
||||
const { length } = textNode.textContent || "";
|
||||
await select({
|
||||
anchorNode: textNode,
|
||||
anchorOffset: length,
|
||||
focusNode: textNode,
|
||||
focusOffset: length,
|
||||
isForward: true,
|
||||
});
|
||||
|
||||
fireEvent.keyDown(textbox, {
|
||||
key: "ArrowDown",
|
||||
});
|
||||
|
||||
// Wait for event dispatch to happen
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(spyDispatcher).toHaveBeenCalledWith({
|
||||
action: Action.EditEvent,
|
||||
event: mockEvent,
|
||||
timelineRenderingType: defaultRoomContext.timelineRenderingType,
|
||||
});
|
||||
});
|
||||
|
||||
it("Should close editing", async () => {
|
||||
// When
|
||||
jest.spyOn(EventUtils, "findEditableEvent").mockReturnValue(undefined);
|
||||
const { textbox, spyDispatcher } = await setup(editorStateTransfer);
|
||||
// Skipping the BR tag
|
||||
const textNode = textbox.childNodes[textbox.childNodes.length - 2];
|
||||
const { length } = textNode.textContent || "";
|
||||
await select({
|
||||
anchorNode: textNode,
|
||||
anchorOffset: length,
|
||||
focusNode: textNode,
|
||||
focusOffset: length,
|
||||
isForward: true,
|
||||
});
|
||||
|
||||
fireEvent.keyDown(textbox, {
|
||||
key: "ArrowDown",
|
||||
});
|
||||
|
||||
// Wait for event dispatch to happen
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(spyDispatcher).toHaveBeenCalledWith({
|
||||
action: Action.EditEvent,
|
||||
event: null,
|
||||
timelineRenderingType: defaultRoomContext.timelineRenderingType,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,200 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`FormattingButtons renders in german 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="mx_FormattingButtons"
|
||||
>
|
||||
<button
|
||||
aria-label="Fett"
|
||||
class="mx_AccessibleButton mx_FormattingButtons_Button mx_FormattingButtons_Button_hover"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
class="mx_FormattingButtons_Icon"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8.8 19c-.55 0-1.02-.196-1.413-.587A1.926 1.926 0 0 1 6.8 17V7c0-.55.196-1.02.588-1.412A1.926 1.926 0 0 1 8.8 5h3.525c1.083 0 2.083.333 3 1 .917.667 1.375 1.592 1.375 2.775 0 .85-.192 1.504-.575 1.963-.383.458-.742.787-1.075.987.417.183.88.525 1.387 1.025.509.5.763 1.25.763 2.25 0 1.483-.542 2.52-1.625 3.113-1.083.591-2.1.887-3.05.887H8.8Zm1.025-2.8h2.6c.8 0 1.287-.204 1.462-.612.175-.409.263-.705.263-.888 0-.183-.088-.48-.263-.887-.175-.409-.687-.613-1.537-.613H9.825v3Zm0-5.7h2.325c.55 0 .95-.142 1.2-.425a1.4 1.4 0 0 0 .375-.95c0-.4-.142-.725-.425-.975-.283-.25-.65-.375-1.1-.375H9.825V10.5Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Kursiv"
|
||||
class="mx_AccessibleButton mx_FormattingButtons_Button mx_FormattingButtons_Button_hover"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
class="mx_FormattingButtons_Icon"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6.25 19c-.35 0-.646-.12-.888-.363A1.207 1.207 0 0 1 5 17.75c0-.35.12-.646.362-.887.242-.242.538-.363.888-.363H9l3-9H9.25c-.35 0-.646-.12-.887-.362A1.207 1.207 0 0 1 8 6.25c0-.35.12-.646.363-.888A1.21 1.21 0 0 1 9.25 5h7.5c.35 0 .646.12.887.362.242.242.363.538.363.888s-.12.646-.363.888a1.207 1.207 0 0 1-.887.362H14.5l-3 9h2.25c.35 0 .646.12.887.363.242.241.363.537.363.887s-.12.646-.363.887a1.207 1.207 0 0 1-.887.363h-7.5Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Unterstrichen"
|
||||
class="mx_AccessibleButton mx_FormattingButtons_Button mx_FormattingButtons_Button_hover"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
class="mx_FormattingButtons_Icon"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 21a.967.967 0 0 1-.713-.288A.968.968 0 0 1 5 20a.97.97 0 0 1 .287-.712A.967.967 0 0 1 6 19h12c.283 0 .52.096.712.288.192.191.288.429.288.712s-.096.52-.288.712A.968.968 0 0 1 18 21H6Zm6-4c-1.683 0-2.992-.525-3.925-1.575-.933-1.05-1.4-2.442-1.4-4.175V4.275c0-.35.13-.65.388-.9A1.27 1.27 0 0 1 7.974 3c.35 0 .65.125.9.375s.375.55.375.9V11.4c0 .933.233 1.692.7 2.275.467.583 1.15.875 2.05.875.9 0 1.583-.292 2.05-.875.467-.583.7-1.342.7-2.275V4.275c0-.35.13-.65.387-.9A1.27 1.27 0 0 1 16.05 3c.35 0 .65.125.9.375s.375.55.375.9v6.975c0 1.733-.467 3.125-1.4 4.175C14.992 16.475 13.683 17 12 17Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Durchgestrichen"
|
||||
class="mx_AccessibleButton mx_FormattingButtons_Button mx_FormattingButtons_Button_hover"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
class="mx_FormattingButtons_Icon"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12.15 20c-1.267 0-2.392-.375-3.375-1.125-.983-.75-1.692-1.775-2.125-3.075l2.2-.95c.233.8.638 1.458 1.213 1.975.574.517 1.287.775 2.137.775.7 0 1.333-.167 1.9-.5.567-.333.85-.867.85-1.6 0-.3-.058-.575-.175-.825A2.362 2.362 0 0 0 14.3 14h2.8a4.279 4.279 0 0 1 .25 1.5c0 1.433-.513 2.542-1.538 3.325C14.788 19.608 13.567 20 12.15 20ZM3 12a.967.967 0 0 1-.712-.287A.968.968 0 0 1 2 11c0-.283.096-.52.288-.712A.967.967 0 0 1 3 10h18c.283 0 .52.096.712.288.192.191.288.429.288.712s-.096.52-.288.713A.968.968 0 0 1 21 12H3Zm9.05-8.15c1.1 0 2.063.27 2.887.813.825.541 1.463 1.37 1.913 2.487l-2.2.975a2.987 2.987 0 0 0-.838-1.3c-.408-.383-.979-.575-1.712-.575-.683 0-1.25.154-1.7.463-.45.308-.7.737-.75 1.287h-2.4c.033-1.15.487-2.13 1.363-2.937.875-.809 2.02-1.213 3.437-1.213Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Ungeordnete Liste"
|
||||
class="mx_AccessibleButton mx_FormattingButtons_Button mx_FormattingButtons_Button_hover"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
class="mx_FormattingButtons_Icon"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M4.5 7.5a1.45 1.45 0 0 1-1.06-.44A1.444 1.444 0 0 1 3 6c0-.412.147-.766.44-1.06A1.45 1.45 0 0 1 4.5 4.5c.412 0 .766.147 1.06.44.293.294.44.647.44 1.06 0 .412-.147.766-.44 1.06-.294.293-.647.44-1.06.44Zm4.787 11.212c.192.192.43.288.713.288h10c.283 0 .52-.096.712-.288A.968.968 0 0 0 21 18a.968.968 0 0 0-.288-.712A.968.968 0 0 0 20 17H10a.967.967 0 0 0-.713.288A.968.968 0 0 0 9 18c0 .283.096.52.287.712Zm0-5.999c.192.191.43.287.713.287h10a.97.97 0 0 0 .712-.287A.968.968 0 0 0 21 12a.968.968 0 0 0-.288-.713A.968.968 0 0 0 20 11H10a.967.967 0 0 0-.713.287A.968.968 0 0 0 9 12c0 .283.096.52.287.713Zm0-6c.192.191.43.287.713.287h10a.97.97 0 0 0 .712-.287A.967.967 0 0 0 21 6a.967.967 0 0 0-.288-.713A.968.968 0 0 0 20 5H10a.968.968 0 0 0-.713.287A.968.968 0 0 0 9 6c0 .283.096.52.287.713ZM3.44 19.06c.294.293.648.44 1.06.44a1.45 1.45 0 0 0 1.06-.44c.293-.294.44-.647.44-1.06 0-.413-.147-.766-.44-1.06a1.445 1.445 0 0 0-1.06-.44 1.45 1.45 0 0 0-1.06.44c-.293.294-.44.647-.44 1.06 0 .413.147.766.44 1.06ZM4.5 13.5a1.45 1.45 0 0 1-1.06-.44A1.445 1.445 0 0 1 3 12c0-.412.147-.766.44-1.06a1.45 1.45 0 0 1 1.06-.44c.412 0 .766.147 1.06.44.293.294.44.648.44 1.06 0 .412-.147.766-.44 1.06-.294.293-.647.44-1.06.44Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Nummerierte Liste"
|
||||
class="mx_AccessibleButton mx_FormattingButtons_Button mx_FormattingButtons_Button_hover"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
class="mx_FormattingButtons_Icon"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M9 6a1 1 0 0 1 1-1h10a1 1 0 1 1 0 2H10a1 1 0 0 1-1-1Zm0 6a1 1 0 0 1 1-1h10a1 1 0 1 1 0 2H10a1 1 0 0 1-1-1Zm0 6a1 1 0 0 1 1-1h10a1 1 0 1 1 0 2H10a1 1 0 0 1-1-1ZM5.604 5.089A.75.75 0 0 1 6 5.75v4.5a.75.75 0 0 1-1.5 0V7.151l-.334.223a.75.75 0 0 1-.832-1.248l1.5-1a.75.75 0 0 1 .77-.037ZM5 13a2.02 2.02 0 0 0-1.139.321 1.846 1.846 0 0 0-.626.719 2.286 2.286 0 0 0-.234.921v.023l-.001.01v.005l.75.001H3a.75.75 0 0 0 1.5.01V15a.789.789 0 0 1 .077-.29.35.35 0 0 1 .116-.14c.04-.027.126-.07.307-.07s.267.043.307.07a.35.35 0 0 1 .116.14.788.788 0 0 1 .076.29v.008a.532.532 0 0 1-.14.352l-2.161 2.351a.748.748 0 0 0-.198.523v.016c0 .414.336.75.75.75h2.5a.75.75 0 0 0 0-1.5h-.82l1.034-1.124C6.809 16 7 15.51 7 15h-.75H7v-.039l-.004-.068a2.285 2.285 0 0 0-.231-.853 1.846 1.846 0 0 0-.626-.719A2.02 2.02 0 0 0 5 13Zm-.5 2.003V15v.01-.008Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Zitieren"
|
||||
class="mx_AccessibleButton mx_FormattingButtons_Button mx_FormattingButtons_Button_hover"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
class="mx_FormattingButtons_Icon"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M4.719 4.34c.094-.642-.366-1.236-1.028-1.328-.663-.092-1.276.354-1.371.996l-.808 5.478c-.094.642.366 1.237 1.028 1.328.663.092 1.276-.354 1.371-.996l.808-5.478Zm12.115 10.174c.095-.642-.366-1.237-1.028-1.328-.662-.092-1.276.354-1.37.996l-.809 5.478c-.094.642.366 1.236 1.028 1.328.663.092 1.277-.354 1.371-.996l.808-5.478ZM9.318 3.009c.665.077 1.138.662 1.058 1.306l-.022.175a220.467 220.467 0 0 1-.266 2.006c-.161 1.171-.368 2.579-.535 3.386-.13.636-.769 1.049-1.425.921-.656-.127-1.082-.745-.95-1.381.148-.72.345-2.052.509-3.237a190.652 190.652 0 0 0 .262-1.981l.021-.17c.08-.644.684-1.103 1.348-1.025Zm13.17 11.505c.094-.642-.366-1.237-1.028-1.328-.663-.092-1.276.354-1.371.996l-.808 5.478c-.094.642.366 1.236 1.028 1.328.663.092 1.276-.354 1.371-.996l.808-5.478Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Code"
|
||||
class="mx_AccessibleButton mx_FormattingButtons_Button mx_FormattingButtons_Button_hover"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
class="mx_FormattingButtons_Icon"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M14.958 5.62a1 1 0 0 0-1.916-.574l-4 13.333a1 1 0 0 0 1.916.575l4-13.333ZM5.974 7.232a1 1 0 0 0-1.409.128l-3.333 4a1 1 0 0 0 0 1.28l3.333 4a1 1 0 1 0 1.537-1.28L3.302 12l2.8-3.36a1 1 0 0 0-.128-1.408Zm12.052 0a1 1 0 0 1 1.409.128l3.333 4a1 1 0 0 1 0 1.28l-3.333 4a1 1 0 1 1-1.537-1.28l2.8-3.36-2.8-3.36a1 1 0 0 1 .128-1.408Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Quelltextblock"
|
||||
class="mx_AccessibleButton mx_FormattingButtons_Button mx_FormattingButtons_Button_hover"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
class="mx_FormattingButtons_Icon"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m8.825 12 1.475-1.475c.2-.2.3-.433.3-.7 0-.267-.1-.5-.3-.7-.2-.2-.438-.3-.713-.3-.274 0-.512.1-.712.3L6.7 11.3c-.1.1-.17.208-.213.325a1.107 1.107 0 0 0-.062.375c0 .133.02.258.063.375a.877.877 0 0 0 .212.325l2.175 2.175c.2.2.438.3.713.3.275 0 .512-.1.712-.3.2-.2.3-.433.3-.7 0-.267-.1-.5-.3-.7L8.825 12Zm6.35 0L13.7 13.475c-.2.2-.3.433-.3.7 0 .267.1.5.3.7.2.2.438.3.713.3.274 0 .512-.1.712-.3L17.3 12.7c.1-.1.17-.208.212-.325.042-.117.063-.242.063-.375s-.02-.258-.063-.375a.877.877 0 0 0-.212-.325l-2.175-2.175a.999.999 0 0 0-1.425 0c-.2.2-.3.433-.3.7 0 .267.1.5.3.7L15.175 12ZM5 21c-.55 0-1.02-.196-1.413-.587A1.926 1.926 0 0 1 3 19V5c0-.55.196-1.02.587-1.413A1.926 1.926 0 0 1 5 3h14c.55 0 1.02.196 1.413.587.39.393.587.863.587 1.413v14c0 .55-.196 1.02-.587 1.413A1.926 1.926 0 0 1 19 21H5Zm0-2h14V5H5v14Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Link"
|
||||
class="mx_AccessibleButton mx_FormattingButtons_Button mx_FormattingButtons_Button_hover"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
class="mx_FormattingButtons_Icon"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 19.071c-.978.978-2.157 1.467-3.536 1.467-1.378 0-2.557-.489-3.535-1.467-.978-.978-1.467-2.157-1.467-3.536 0-1.378.489-2.557 1.467-3.535L7.05 9.879c.2-.2.436-.3.707-.3.271 0 .507.1.707.3.2.2.301.436.301.707 0 .27-.1.506-.3.707l-2.122 2.121a2.893 2.893 0 0 0-.884 2.122c0 .824.295 1.532.884 2.12.59.59 1.296.885 2.121.885s1.533-.295 2.122-.884l2.121-2.121c.2-.2.436-.301.707-.301.271 0 .507.1.707.3.2.2.3.437.3.708 0 .27-.1.506-.3.707L12 19.07Zm-1.414-4.243c-.2.2-.436.3-.707.3a.967.967 0 0 1-.707-.3.969.969 0 0 1-.301-.707c0-.27.1-.507.3-.707l4.243-4.242c.2-.2.436-.301.707-.301.271 0 .507.1.707.3.2.2.3.437.3.708 0 .27-.1.506-.3.707l-4.242 4.242Zm6.364-.707c-.2.2-.436.3-.707.3a.968.968 0 0 1-.707-.3.969.969 0 0 1-.301-.707c0-.27.1-.507.3-.707l2.122-2.121c.59-.59.884-1.297.884-2.122s-.295-1.532-.884-2.12a2.893 2.893 0 0 0-2.121-.885c-.825 0-1.532.295-2.122.884l-2.121 2.121c-.2.2-.436.301-.707.301a.968.968 0 0 1-.707-.3.97.97 0 0 1-.3-.708c0-.27.1-.506.3-.707L12 4.93c.978-.978 2.157-1.467 3.536-1.467 1.378 0 2.557.489 3.535 1.467.978.978 1.467 2.157 1.467 3.535 0 1.38-.489 2.558-1.467 3.536l-2.121 2.121Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
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 { renderHook } from "@testing-library/react-hooks";
|
||||
import { act } from "jest-matrix-react";
|
||||
|
||||
import { usePlainTextListeners } from "../../../../../../../src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners";
|
||||
|
||||
describe("setContent", () => {
|
||||
it("calling with a string calls the onChange argument", () => {
|
||||
const mockOnChange = jest.fn();
|
||||
const { result } = renderHook(() => usePlainTextListeners("initialContent", mockOnChange));
|
||||
|
||||
const newContent = "new content";
|
||||
act(() => {
|
||||
result.current.setContent(newContent);
|
||||
});
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(newContent);
|
||||
});
|
||||
|
||||
it("calling with no argument and no editor ref does not call onChange", () => {
|
||||
const mockOnChange = jest.fn();
|
||||
const { result } = renderHook(() => usePlainTextListeners("initialContent", mockOnChange));
|
||||
|
||||
act(() => {
|
||||
result.current.setContent();
|
||||
});
|
||||
|
||||
expect(mockOnChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calling with no argument and a valid editor ref calls onChange with the editorRef innerHTML", () => {
|
||||
const mockOnChange = jest.fn();
|
||||
|
||||
// create a div to represent the editor and append some content
|
||||
const mockEditor = document.createElement("div");
|
||||
const mockEditorText = "some text content";
|
||||
const textNode = document.createTextNode(mockEditorText);
|
||||
mockEditor.appendChild(textNode);
|
||||
|
||||
const { result } = renderHook(() => usePlainTextListeners("initialContent", mockOnChange));
|
||||
|
||||
// @ts-ignore in order to allow us to reassign the ref without complaint
|
||||
result.current.ref.current = mockEditor;
|
||||
|
||||
act(() => {
|
||||
result.current.setContent();
|
||||
});
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(mockEditor.innerHTML);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,419 @@
|
|||
/*
|
||||
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 {
|
||||
Suggestion,
|
||||
findSuggestionInText,
|
||||
getMappedSuggestion,
|
||||
processCommand,
|
||||
processEmojiReplacement,
|
||||
processMention,
|
||||
processSelectionChange,
|
||||
} from "../../../../../../../src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion";
|
||||
|
||||
function createMockPlainTextSuggestionPattern(props: Partial<Suggestion> = {}): Suggestion {
|
||||
return {
|
||||
mappedSuggestion: { keyChar: "/", type: "command", text: "some text", ...props.mappedSuggestion },
|
||||
node: document.createTextNode(""),
|
||||
startOffset: 0,
|
||||
endOffset: 0,
|
||||
...props,
|
||||
};
|
||||
}
|
||||
|
||||
function createMockCustomSuggestionPattern(props: Partial<Suggestion> = {}): Suggestion {
|
||||
return {
|
||||
mappedSuggestion: { keyChar: "", type: "custom", text: "🙂", ...props.mappedSuggestion },
|
||||
node: document.createTextNode(":)"),
|
||||
startOffset: 0,
|
||||
endOffset: 2,
|
||||
...props,
|
||||
};
|
||||
}
|
||||
|
||||
describe("processCommand", () => {
|
||||
it("does not change parent hook state if suggestion is null", () => {
|
||||
// create a mockSuggestion using the text node above
|
||||
const mockSetSuggestion = jest.fn();
|
||||
const mockSetText = jest.fn();
|
||||
|
||||
// call the function with a null suggestion
|
||||
processCommand("should not be seen", null, mockSetSuggestion, mockSetText);
|
||||
|
||||
// check that the parent state setter has not been called
|
||||
expect(mockSetText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("can change the parent hook state when required", () => {
|
||||
// create a div and append a text node to it with some initial text
|
||||
const editorDiv = document.createElement("div");
|
||||
const initialText = "text";
|
||||
const textNode = document.createTextNode(initialText);
|
||||
editorDiv.appendChild(textNode);
|
||||
|
||||
// create a mockSuggestion using the text node above
|
||||
const mockSuggestion = createMockPlainTextSuggestionPattern({ node: textNode });
|
||||
const mockSetSuggestion = jest.fn();
|
||||
const mockSetText = jest.fn();
|
||||
const replacementText = "/replacement text";
|
||||
|
||||
processCommand(replacementText, mockSuggestion, mockSetSuggestion, mockSetText);
|
||||
|
||||
// check that the text has changed and includes a trailing space
|
||||
expect(mockSetText).toHaveBeenCalledWith(`${replacementText} `);
|
||||
});
|
||||
});
|
||||
|
||||
describe("processEmojiReplacement", () => {
|
||||
it("does not change parent hook state if suggestion is null", () => {
|
||||
// create a mockSuggestion using the text node above
|
||||
const mockSetSuggestion = jest.fn();
|
||||
const mockSetText = jest.fn();
|
||||
|
||||
// call the function with a null suggestion
|
||||
processEmojiReplacement(null, mockSetSuggestion, mockSetText);
|
||||
|
||||
// check that the parent state setter has not been called
|
||||
expect(mockSetText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("can change the parent hook state when required", () => {
|
||||
// create a div and append a text node to it with some initial text
|
||||
const editorDiv = document.createElement("div");
|
||||
const initialText = ":)";
|
||||
const textNode = document.createTextNode(initialText);
|
||||
editorDiv.appendChild(textNode);
|
||||
|
||||
// create a mockSuggestion using the text node above
|
||||
const mockSuggestion = createMockCustomSuggestionPattern({ node: textNode });
|
||||
const mockSetSuggestion = jest.fn();
|
||||
const mockSetText = jest.fn();
|
||||
const replacementText = "🙂";
|
||||
|
||||
processEmojiReplacement(mockSuggestion, mockSetSuggestion, mockSetText);
|
||||
|
||||
// check that the text has changed and includes a trailing space
|
||||
expect(mockSetText).toHaveBeenCalledWith(replacementText);
|
||||
});
|
||||
});
|
||||
|
||||
describe("processMention", () => {
|
||||
// TODO refactor and expand tests when mentions become <a> tags
|
||||
it("returns early when suggestion is null", () => {
|
||||
const mockSetSuggestion = jest.fn();
|
||||
const mockSetText = jest.fn();
|
||||
processMention("href", "displayName", new Map(), null, mockSetSuggestion, mockSetText);
|
||||
|
||||
expect(mockSetSuggestion).not.toHaveBeenCalled();
|
||||
expect(mockSetText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("can insert a mention into a text node", () => {
|
||||
// make a text node and an editor div, set the cursor inside the text node and then
|
||||
// append node to editor, then editor to document
|
||||
const textNode = document.createTextNode("@a");
|
||||
const mockEditor = document.createElement("div");
|
||||
mockEditor.appendChild(textNode);
|
||||
document.body.appendChild(mockEditor);
|
||||
document.getSelection()?.setBaseAndExtent(textNode, 1, textNode, 1);
|
||||
|
||||
// call the util function
|
||||
const href = "href";
|
||||
const displayName = "displayName";
|
||||
const mockSetSuggestionData = jest.fn();
|
||||
const mockSetText = jest.fn();
|
||||
processMention(
|
||||
href,
|
||||
displayName,
|
||||
new Map([["style", "test"]]),
|
||||
{ node: textNode, startOffset: 0, endOffset: 2 } as unknown as Suggestion,
|
||||
mockSetSuggestionData,
|
||||
mockSetText,
|
||||
);
|
||||
|
||||
// check that the editor has a single child
|
||||
expect(mockEditor.children).toHaveLength(1);
|
||||
const linkElement = mockEditor.firstElementChild as HTMLElement;
|
||||
|
||||
// and that the child is an <a> tag with the expected attributes and content
|
||||
expect(linkElement).toBeInstanceOf(HTMLAnchorElement);
|
||||
expect(linkElement).toHaveAttribute(href, href);
|
||||
expect(linkElement).toHaveAttribute("contenteditable", "false");
|
||||
expect(linkElement).toHaveAttribute("style", "test");
|
||||
expect(linkElement.textContent).toBe(displayName);
|
||||
|
||||
expect(mockSetText).toHaveBeenCalledWith();
|
||||
expect(mockSetSuggestionData).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("processSelectionChange", () => {
|
||||
function createMockEditorRef(element: HTMLDivElement | null = null): React.RefObject<HTMLDivElement> {
|
||||
return { current: element } as React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
function appendEditorWithTextNodeContaining(initialText = ""): [HTMLDivElement, Node] {
|
||||
// create the elements/nodes
|
||||
const mockEditor = document.createElement("div");
|
||||
const textNode = document.createTextNode(initialText);
|
||||
|
||||
// append text node to the editor, editor to the document body
|
||||
mockEditor.appendChild(textNode);
|
||||
document.body.appendChild(mockEditor);
|
||||
|
||||
return [mockEditor, textNode];
|
||||
}
|
||||
|
||||
const mockSetSuggestion = jest.fn();
|
||||
beforeEach(() => {
|
||||
mockSetSuggestion.mockClear();
|
||||
});
|
||||
|
||||
it("returns early if current editorRef is null", () => {
|
||||
const mockEditorRef = createMockEditorRef(null);
|
||||
// we monitor for the call to document.createNodeIterator to indicate an early return
|
||||
const nodeIteratorSpy = jest.spyOn(document, "createNodeIterator");
|
||||
|
||||
processSelectionChange(mockEditorRef, jest.fn());
|
||||
expect(nodeIteratorSpy).not.toHaveBeenCalled();
|
||||
|
||||
// tidy up to avoid potential impacts on other tests
|
||||
nodeIteratorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("calls setSuggestion with null if selection is not a cursor", () => {
|
||||
const [mockEditor, textNode] = appendEditorWithTextNodeContaining("content");
|
||||
const mockEditorRef = createMockEditorRef(mockEditor);
|
||||
|
||||
// create a selection in the text node that has different start and end locations ie it
|
||||
// is not a cursor
|
||||
document.getSelection()?.setBaseAndExtent(textNode, 0, textNode, 4);
|
||||
|
||||
// process the selection and check that we do not attempt to set the suggestion
|
||||
processSelectionChange(mockEditorRef, mockSetSuggestion);
|
||||
expect(mockSetSuggestion).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("calls setSuggestion with null if selection cursor is not inside a text node", () => {
|
||||
const [mockEditor] = appendEditorWithTextNodeContaining("content");
|
||||
const mockEditorRef = createMockEditorRef(mockEditor);
|
||||
|
||||
// create a selection that points at the editor element, not the text node it contains
|
||||
document.getSelection()?.setBaseAndExtent(mockEditor, 0, mockEditor, 0);
|
||||
|
||||
// process the selection and check that we do not attempt to set the suggestion
|
||||
processSelectionChange(mockEditorRef, mockSetSuggestion);
|
||||
expect(mockSetSuggestion).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("calls setSuggestion with null if we have an existing suggestion but no command match", () => {
|
||||
const [mockEditor, textNode] = appendEditorWithTextNodeContaining("content");
|
||||
const mockEditorRef = createMockEditorRef(mockEditor);
|
||||
|
||||
// create a selection in the text node that has identical start and end locations, ie it is a cursor
|
||||
document.getSelection()?.setBaseAndExtent(textNode, 0, textNode, 0);
|
||||
|
||||
// the call to process the selection will have an existing suggestion in state due to the second
|
||||
// argument being non-null, expect that we clear this suggestion now that the text is not a command
|
||||
processSelectionChange(mockEditorRef, mockSetSuggestion);
|
||||
expect(mockSetSuggestion).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("calls setSuggestion with the expected arguments when text node is valid command", () => {
|
||||
const commandText = "/potentialCommand";
|
||||
const [mockEditor, textNode] = appendEditorWithTextNodeContaining(commandText);
|
||||
const mockEditorRef = createMockEditorRef(mockEditor);
|
||||
|
||||
// create a selection in the text node that has identical start and end locations, ie it is a cursor
|
||||
document.getSelection()?.setBaseAndExtent(textNode, 3, textNode, 3);
|
||||
|
||||
// process the change and check the suggestion that is set looks as we expect it to
|
||||
processSelectionChange(mockEditorRef, mockSetSuggestion);
|
||||
expect(mockSetSuggestion).toHaveBeenCalledWith({
|
||||
mappedSuggestion: {
|
||||
keyChar: "/",
|
||||
type: "command",
|
||||
text: "potentialCommand",
|
||||
},
|
||||
node: textNode,
|
||||
startOffset: 0,
|
||||
endOffset: commandText.length,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat a command outside the first text node to be a suggestion", () => {
|
||||
const [mockEditor] = appendEditorWithTextNodeContaining("some text in first node");
|
||||
const [, commandTextNode] = appendEditorWithTextNodeContaining("/potentialCommand");
|
||||
|
||||
const mockEditorRef = createMockEditorRef(mockEditor);
|
||||
|
||||
// create a selection in the text node that has identical start and end locations, ie it is a cursor
|
||||
document.getSelection()?.setBaseAndExtent(commandTextNode, 3, commandTextNode, 3);
|
||||
|
||||
// process the change and check the suggestion that is set looks as we expect it to
|
||||
processSelectionChange(mockEditorRef, mockSetSuggestion);
|
||||
expect(mockSetSuggestion).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findSuggestionInText", () => {
|
||||
const command = "/someCommand";
|
||||
const userMention = "@userMention";
|
||||
const roomMention = "#roomMention";
|
||||
|
||||
const mentionTestCases = [userMention, roomMention];
|
||||
const allTestCases = [command, userMention, roomMention];
|
||||
|
||||
it("returns null if content does not contain any mention or command characters", () => {
|
||||
expect(findSuggestionInText("hello", 1, true)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null if content contains a command but is not the first text node", () => {
|
||||
expect(findSuggestionInText(command, 1, false)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null if the offset is outside the content length", () => {
|
||||
expect(findSuggestionInText("hi", 30, true)).toBeNull();
|
||||
expect(findSuggestionInText("hi", -10, true)).toBeNull();
|
||||
});
|
||||
|
||||
it.each(allTestCases)("returns an object when the whole input is special case: %s", (text) => {
|
||||
const expected = {
|
||||
mappedSuggestion: getMappedSuggestion(text),
|
||||
startOffset: 0,
|
||||
endOffset: text.length,
|
||||
};
|
||||
// test for cursor immediately before and after special character, before end, at end
|
||||
expect(findSuggestionInText(text, 0, true)).toEqual(expected);
|
||||
expect(findSuggestionInText(text, 1, true)).toEqual(expected);
|
||||
expect(findSuggestionInText(text, text.length - 2, true)).toEqual(expected);
|
||||
expect(findSuggestionInText(text, text.length, true)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("returns null when a command is followed by other text", () => {
|
||||
const followingText = " followed by something";
|
||||
|
||||
// check for cursor inside and outside the command
|
||||
expect(findSuggestionInText(command + followingText, command.length - 2, true)).toBeNull();
|
||||
expect(findSuggestionInText(command + followingText, command.length + 2, true)).toBeNull();
|
||||
});
|
||||
|
||||
it.each(mentionTestCases)("returns an object when a %s is followed by other text", (mention) => {
|
||||
const followingText = " followed by something else";
|
||||
expect(findSuggestionInText(mention + followingText, mention.length - 2, true)).toEqual({
|
||||
mappedSuggestion: getMappedSuggestion(mention),
|
||||
startOffset: 0,
|
||||
endOffset: mention.length,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null if there is a command surrounded by text", () => {
|
||||
const precedingText = "text before the command ";
|
||||
const followingText = " text after the command";
|
||||
expect(
|
||||
findSuggestionInText(precedingText + command + followingText, precedingText.length + 4, true),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each(mentionTestCases)("returns an object if %s is surrounded by text", (mention) => {
|
||||
const precedingText = "I want to mention ";
|
||||
const followingText = " in my message";
|
||||
|
||||
const textInput = precedingText + mention + followingText;
|
||||
const expected = {
|
||||
mappedSuggestion: getMappedSuggestion(mention),
|
||||
startOffset: precedingText.length,
|
||||
endOffset: precedingText.length + mention.length,
|
||||
};
|
||||
|
||||
// when the cursor is immediately before the special character
|
||||
expect(findSuggestionInText(textInput, precedingText.length, true)).toEqual(expected);
|
||||
// when the cursor is inside the mention
|
||||
expect(findSuggestionInText(textInput, precedingText.length + 3, true)).toEqual(expected);
|
||||
// when the cursor is right at the end of the mention
|
||||
expect(findSuggestionInText(textInput, precedingText.length + mention.length, true)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("returns null for text content with an email address", () => {
|
||||
const emailInput = "send to user@test.com";
|
||||
expect(findSuggestionInText(emailInput, 15, true)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for double slashed command", () => {
|
||||
const doubleSlashCommand = "//not a command";
|
||||
expect(findSuggestionInText(doubleSlashCommand, 4, true)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for slash separated text", () => {
|
||||
const slashSeparatedInput = "please to this/that/the other";
|
||||
expect(findSuggestionInText(slashSeparatedInput, 21, true)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns an object for a mention that contains punctuation", () => {
|
||||
const mentionWithPunctuation = "@userX14#5a_-";
|
||||
const precedingText = "mention ";
|
||||
const mentionInput = precedingText + mentionWithPunctuation;
|
||||
expect(findSuggestionInText(mentionInput, 12, true)).toEqual({
|
||||
mappedSuggestion: getMappedSuggestion(mentionWithPunctuation),
|
||||
startOffset: precedingText.length,
|
||||
endOffset: precedingText.length + mentionWithPunctuation.length,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when user inputs any whitespace after the special character", () => {
|
||||
const mentionWithSpaceAfter = "@ somebody";
|
||||
expect(findSuggestionInText(mentionWithSpaceAfter, 2, true)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns an object for an emoji suggestion", () => {
|
||||
const emoiticon = ":)";
|
||||
const precedingText = "hello ";
|
||||
const mentionInput = precedingText + emoiticon;
|
||||
expect(findSuggestionInText(mentionInput, precedingText.length, true, true)).toEqual({
|
||||
mappedSuggestion: getMappedSuggestion(emoiticon, true),
|
||||
startOffset: precedingText.length,
|
||||
endOffset: precedingText.length + emoiticon.length,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMappedSuggestion", () => {
|
||||
it("returns null when the first character is not / # @", () => {
|
||||
expect(getMappedSuggestion("Zzz")).toBe(null);
|
||||
});
|
||||
|
||||
it("returns the expected mapped suggestion when first character is # or @", () => {
|
||||
expect(getMappedSuggestion("@user-mention")).toEqual({
|
||||
type: "mention",
|
||||
keyChar: "@",
|
||||
text: "user-mention",
|
||||
});
|
||||
expect(getMappedSuggestion("#room-mention")).toEqual({
|
||||
type: "mention",
|
||||
keyChar: "#",
|
||||
text: "room-mention",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the expected mapped suggestion when first character is /", () => {
|
||||
expect(getMappedSuggestion("/command")).toEqual({
|
||||
type: "command",
|
||||
keyChar: "/",
|
||||
text: "command",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the expected mapped suggestion when the text is a plain text emoiticon", () => {
|
||||
expect(getMappedSuggestion(":)", true)).toEqual({
|
||||
type: "custom",
|
||||
keyChar: "",
|
||||
text: "🙂",
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,305 @@
|
|||
/*
|
||||
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 { IEventRelation, MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { waitFor } from "jest-matrix-react";
|
||||
|
||||
import { TimelineRenderingType } from "../../../../../../../src/contexts/RoomContext";
|
||||
import { mkStubRoom, stubClient } from "../../../../../../test-utils";
|
||||
import ContentMessages from "../../../../../../../src/ContentMessages";
|
||||
import { IRoomState } from "../../../../../../../src/components/structures/RoomView";
|
||||
import {
|
||||
handleClipboardEvent,
|
||||
isEventToHandleAsClipboardEvent,
|
||||
} from "../../../../../../../src/components/views/rooms/wysiwyg_composer/hooks/utils";
|
||||
|
||||
const mockClient = stubClient();
|
||||
const mockRoom = mkStubRoom("mock room", "mock room", mockClient);
|
||||
const mockRoomState = {
|
||||
room: mockRoom,
|
||||
timelineRenderingType: TimelineRenderingType.Room,
|
||||
replyToEvent: {} as unknown as MatrixEvent,
|
||||
} as unknown as IRoomState;
|
||||
|
||||
const sendContentListToRoomSpy = jest.spyOn(ContentMessages.sharedInstance(), "sendContentListToRoom");
|
||||
const sendContentToRoomSpy = jest.spyOn(ContentMessages.sharedInstance(), "sendContentToRoom");
|
||||
const fetchSpy = jest.spyOn(window, "fetch");
|
||||
const logSpy = jest.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
describe("handleClipboardEvent", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
function createMockClipboardEvent(props: any): ClipboardEvent {
|
||||
return { clipboardData: { files: [], types: [] }, ...props } as ClipboardEvent;
|
||||
}
|
||||
|
||||
it("returns false if it is not a paste event", () => {
|
||||
const originalEvent = createMockClipboardEvent({ type: "copy" });
|
||||
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockRoomState, mockClient);
|
||||
|
||||
expect(output).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false if clipboard data is null", () => {
|
||||
const originalEvent = createMockClipboardEvent({ type: "paste", clipboardData: null });
|
||||
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockRoomState, mockClient);
|
||||
|
||||
expect(output).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false if room is undefined", () => {
|
||||
const originalEvent = createMockClipboardEvent({ type: "paste" });
|
||||
const { room, ...roomStateWithoutRoom } = mockRoomState;
|
||||
const output = handleClipboardEvent(
|
||||
originalEvent,
|
||||
originalEvent.clipboardData,
|
||||
roomStateWithoutRoom,
|
||||
mockClient,
|
||||
);
|
||||
|
||||
expect(output).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false if room clipboardData files and types are empty", () => {
|
||||
const originalEvent = createMockClipboardEvent({
|
||||
type: "paste",
|
||||
clipboardData: { files: [], types: [] },
|
||||
});
|
||||
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockRoomState, mockClient);
|
||||
expect(output).toBe(false);
|
||||
});
|
||||
|
||||
it("handles event and calls sendContentListToRoom when data files are present", () => {
|
||||
const originalEvent = createMockClipboardEvent({
|
||||
type: "paste",
|
||||
clipboardData: { files: ["something here"], types: [] },
|
||||
});
|
||||
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockRoomState, mockClient);
|
||||
|
||||
expect(sendContentListToRoomSpy).toHaveBeenCalledTimes(1);
|
||||
expect(sendContentListToRoomSpy).toHaveBeenCalledWith(
|
||||
originalEvent.clipboardData?.files,
|
||||
mockRoom.roomId,
|
||||
undefined, // this is the event relation, an optional arg
|
||||
mockClient,
|
||||
mockRoomState.timelineRenderingType,
|
||||
);
|
||||
expect(output).toBe(true);
|
||||
});
|
||||
|
||||
it("calls sendContentListToRoom with eventRelation when present", () => {
|
||||
const originalEvent = createMockClipboardEvent({
|
||||
type: "paste",
|
||||
clipboardData: { files: ["something here"], types: [] },
|
||||
});
|
||||
const mockEventRelation = {} as unknown as IEventRelation;
|
||||
const output = handleClipboardEvent(
|
||||
originalEvent,
|
||||
originalEvent.clipboardData,
|
||||
mockRoomState,
|
||||
mockClient,
|
||||
mockEventRelation,
|
||||
);
|
||||
|
||||
expect(sendContentListToRoomSpy).toHaveBeenCalledTimes(1);
|
||||
expect(sendContentListToRoomSpy).toHaveBeenCalledWith(
|
||||
originalEvent.clipboardData?.files,
|
||||
mockRoom.roomId,
|
||||
mockEventRelation, // this is the event relation, an optional arg
|
||||
mockClient,
|
||||
mockRoomState.timelineRenderingType,
|
||||
);
|
||||
expect(output).toBe(true);
|
||||
});
|
||||
|
||||
it("calls the error handler when sentContentListToRoom errors", async () => {
|
||||
const mockErrorMessage = "something went wrong";
|
||||
sendContentListToRoomSpy.mockRejectedValueOnce(new Error(mockErrorMessage));
|
||||
|
||||
const originalEvent = createMockClipboardEvent({
|
||||
type: "paste",
|
||||
clipboardData: { files: ["something here"], types: [] },
|
||||
});
|
||||
const mockEventRelation = {} as unknown as IEventRelation;
|
||||
const output = handleClipboardEvent(
|
||||
originalEvent,
|
||||
originalEvent.clipboardData,
|
||||
mockRoomState,
|
||||
mockClient,
|
||||
mockEventRelation,
|
||||
);
|
||||
|
||||
expect(sendContentListToRoomSpy).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => {
|
||||
expect(logSpy).toHaveBeenCalledWith(mockErrorMessage);
|
||||
});
|
||||
expect(output).toBe(true);
|
||||
});
|
||||
|
||||
it("calls the error handler when data types has text/html but data can not be parsed", () => {
|
||||
const originalEvent = createMockClipboardEvent({
|
||||
type: "paste",
|
||||
clipboardData: {
|
||||
files: [],
|
||||
types: ["text/html"],
|
||||
getData: jest.fn().mockReturnValue("<div>invalid html"),
|
||||
},
|
||||
});
|
||||
const mockEventRelation = {} as unknown as IEventRelation;
|
||||
const output = handleClipboardEvent(
|
||||
originalEvent,
|
||||
originalEvent.clipboardData,
|
||||
mockRoomState,
|
||||
mockClient,
|
||||
mockEventRelation,
|
||||
);
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith("Failed to handle pasted content as Safari inserted content");
|
||||
expect(output).toBe(false);
|
||||
});
|
||||
|
||||
it("calls fetch when data types has text/html and data can parsed", () => {
|
||||
const originalEvent = createMockClipboardEvent({
|
||||
type: "paste",
|
||||
clipboardData: {
|
||||
files: [],
|
||||
types: ["text/html"],
|
||||
getData: jest.fn().mockReturnValue(`<img src="blob:" />`),
|
||||
},
|
||||
});
|
||||
const mockEventRelation = {} as unknown as IEventRelation;
|
||||
handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockRoomState, mockClient, mockEventRelation);
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(fetchSpy).toHaveBeenCalledWith("blob:");
|
||||
});
|
||||
|
||||
it("calls error handler when fetch fails", async () => {
|
||||
const mockErrorMessage = "fetch failed";
|
||||
fetchSpy.mockRejectedValueOnce(mockErrorMessage);
|
||||
const originalEvent = createMockClipboardEvent({
|
||||
type: "paste",
|
||||
clipboardData: {
|
||||
files: [],
|
||||
types: ["text/html"],
|
||||
getData: jest.fn().mockReturnValue(`<img src="blob:" />`),
|
||||
},
|
||||
});
|
||||
const mockEventRelation = {} as unknown as IEventRelation;
|
||||
const output = handleClipboardEvent(
|
||||
originalEvent,
|
||||
originalEvent.clipboardData,
|
||||
mockRoomState,
|
||||
mockClient,
|
||||
mockEventRelation,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(logSpy).toHaveBeenCalledWith(mockErrorMessage);
|
||||
});
|
||||
expect(output).toBe(true);
|
||||
});
|
||||
|
||||
it("calls sendContentToRoom when parsing is successful", async () => {
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
url: "test/file",
|
||||
blob: () => {
|
||||
return Promise.resolve({ type: "image/jpeg" } as Blob);
|
||||
},
|
||||
} as Response);
|
||||
|
||||
const originalEvent = createMockClipboardEvent({
|
||||
type: "paste",
|
||||
clipboardData: {
|
||||
files: [],
|
||||
types: ["text/html"],
|
||||
getData: jest.fn().mockReturnValue(`<img src="blob:" />`),
|
||||
},
|
||||
});
|
||||
const mockEventRelation = {} as unknown as IEventRelation;
|
||||
const output = handleClipboardEvent(
|
||||
originalEvent,
|
||||
originalEvent.clipboardData,
|
||||
mockRoomState,
|
||||
mockClient,
|
||||
mockEventRelation,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sendContentToRoomSpy).toHaveBeenCalledWith(
|
||||
expect.any(File),
|
||||
mockRoom.roomId,
|
||||
mockEventRelation,
|
||||
mockClient,
|
||||
mockRoomState.replyToEvent,
|
||||
);
|
||||
});
|
||||
expect(output).toBe(true);
|
||||
});
|
||||
|
||||
it("calls error handler when parsing is not successful", async () => {
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
url: "test/file",
|
||||
blob: () => {
|
||||
return Promise.resolve({ type: "image/jpeg" } as Blob);
|
||||
},
|
||||
} as Response);
|
||||
const mockErrorMessage = "sendContentToRoom failed";
|
||||
sendContentToRoomSpy.mockRejectedValueOnce(mockErrorMessage);
|
||||
|
||||
const originalEvent = createMockClipboardEvent({
|
||||
type: "paste",
|
||||
clipboardData: {
|
||||
files: [],
|
||||
types: ["text/html"],
|
||||
getData: jest.fn().mockReturnValue(`<img src="blob:" />`),
|
||||
},
|
||||
});
|
||||
const mockEventRelation = {} as unknown as IEventRelation;
|
||||
const output = handleClipboardEvent(
|
||||
originalEvent,
|
||||
originalEvent.clipboardData,
|
||||
mockRoomState,
|
||||
mockClient,
|
||||
mockEventRelation,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(logSpy).toHaveBeenCalledWith(mockErrorMessage);
|
||||
});
|
||||
expect(output).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isEventToHandleAsClipboardEvent", () => {
|
||||
it("returns true for ClipboardEvent", () => {
|
||||
const input = new ClipboardEvent("clipboard");
|
||||
expect(isEventToHandleAsClipboardEvent(input)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for special case input", () => {
|
||||
const input = new InputEvent("insertFromPaste", { inputType: "insertFromPaste" });
|
||||
Object.assign(input, { dataTransfer: "not null" });
|
||||
expect(isEventToHandleAsClipboardEvent(input)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for regular InputEvent", () => {
|
||||
const input = new InputEvent("input");
|
||||
expect(isEventToHandleAsClipboardEvent(input)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for other input", () => {
|
||||
const input = new KeyboardEvent("keyboard");
|
||||
expect(isEventToHandleAsClipboardEvent(input)).toBe(false);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
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 { EventTimeline, MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { getRoomContext, mkEvent, mkStubRoom, stubClient } from "../../../../../test-utils";
|
||||
import { IRoomState } from "../../../../../../src/components/structures/RoomView";
|
||||
import EditorStateTransfer from "../../../../../../src/utils/EditorStateTransfer";
|
||||
|
||||
export function createMocks(eventContent = "Replying <strong>to</strong> this new content") {
|
||||
const mockClient = stubClient();
|
||||
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: eventContent,
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
const mockRoom = mkStubRoom("myfakeroom", "myfakeroom", mockClient) as any;
|
||||
mockRoom.findEventById = jest.fn((eventId) => {
|
||||
return eventId === mockEvent.getId() ? mockEvent : null;
|
||||
});
|
||||
|
||||
const defaultRoomContext: IRoomState = getRoomContext(mockRoom, {
|
||||
liveTimeline: { getEvents: (): MatrixEvent[] => [] } as unknown as EventTimeline,
|
||||
});
|
||||
|
||||
const editorStateTransfer = new EditorStateTransfer(mockEvent);
|
||||
|
||||
return { defaultRoomContext, editorStateTransfer, mockClient, mockEvent };
|
||||
}
|
|
@ -0,0 +1,271 @@
|
|||
/*
|
||||
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 { mocked } from "jest-mock";
|
||||
import React from "react";
|
||||
|
||||
import { ICompletion } from "../../../../../../../src/autocomplete/Autocompleter";
|
||||
import {
|
||||
buildQuery,
|
||||
getRoomFromCompletion,
|
||||
getMentionDisplayText,
|
||||
getMentionAttributes,
|
||||
} from "../../../../../../../src/components/views/rooms/wysiwyg_composer/utils/autocomplete";
|
||||
import { createTestClient, mkRoom } from "../../../../../../test-utils";
|
||||
import * as _mockAvatar from "../../../../../../../src/Avatar";
|
||||
|
||||
const mockClient = createTestClient();
|
||||
const mockRoomId = "mockRoomId";
|
||||
const mockRoom = mkRoom(mockClient, mockRoomId);
|
||||
|
||||
const createMockCompletion = (props: Partial<ICompletion>): ICompletion => {
|
||||
return {
|
||||
completion: "mock",
|
||||
range: { beginning: true, start: 0, end: 0 },
|
||||
component: React.createElement("div"),
|
||||
...props,
|
||||
};
|
||||
};
|
||||
|
||||
jest.mock("../../../../../../../src/Avatar");
|
||||
jest.mock("../../../../../../../src/stores/WidgetStore");
|
||||
jest.mock("../../../../../../../src/stores/widgets/WidgetLayoutStore");
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
afterAll(() => jest.restoreAllMocks());
|
||||
|
||||
describe("buildQuery", () => {
|
||||
it("returns an empty string for a falsy argument", () => {
|
||||
expect(buildQuery(null)).toBe("");
|
||||
});
|
||||
|
||||
it("returns an empty string when keyChar is falsy", () => {
|
||||
const noKeyCharSuggestion = { keyChar: "" as const, text: "test", type: "unknown" as const };
|
||||
expect(buildQuery(noKeyCharSuggestion)).toBe("");
|
||||
});
|
||||
|
||||
it("combines the keyChar and text of the suggestion in the query", () => {
|
||||
const handledSuggestion = { keyChar: "@" as const, text: "alice", type: "mention" as const };
|
||||
expect(buildQuery(handledSuggestion)).toBe("@alice");
|
||||
|
||||
const handledCommand = { keyChar: "/" as const, text: "spoiler", type: "mention" as const };
|
||||
expect(buildQuery(handledCommand)).toBe("/spoiler");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRoomFromCompletion", () => {
|
||||
const createMockRoomCompletion = (props: Partial<ICompletion>): ICompletion => {
|
||||
return createMockCompletion({ ...props, type: "room" });
|
||||
};
|
||||
|
||||
it("calls getRoom with completionId if present in the completion", () => {
|
||||
const testId = "arbitraryId";
|
||||
const completionWithId = createMockRoomCompletion({ completionId: testId });
|
||||
|
||||
getRoomFromCompletion(completionWithId, mockClient);
|
||||
|
||||
expect(mockClient.getRoom).toHaveBeenCalledWith(testId);
|
||||
});
|
||||
|
||||
it("calls getRoom with completion if present and correct format", () => {
|
||||
const testCompletion = "arbitraryCompletion";
|
||||
const completionWithId = createMockRoomCompletion({ completionId: testCompletion });
|
||||
|
||||
getRoomFromCompletion(completionWithId, mockClient);
|
||||
|
||||
expect(mockClient.getRoom).toHaveBeenCalledWith(testCompletion);
|
||||
});
|
||||
|
||||
it("calls getRooms if no completionId is present and completion starts with #", () => {
|
||||
const completionWithId = createMockRoomCompletion({ completion: "#hash" });
|
||||
|
||||
const result = getRoomFromCompletion(completionWithId, mockClient);
|
||||
|
||||
expect(mockClient.getRoom).not.toHaveBeenCalled();
|
||||
expect(mockClient.getRooms).toHaveBeenCalled();
|
||||
|
||||
// in this case, because the mock client returns an empty array of rooms
|
||||
// from the call to get rooms, we'd expect the result to be null
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMentionDisplayText", () => {
|
||||
it("returns an empty string if we are not handling a user, room or at-room type", () => {
|
||||
const nonHandledCompletionTypes = ["community", "command"] as const;
|
||||
const nonHandledCompletions = nonHandledCompletionTypes.map((type) => createMockCompletion({ type }));
|
||||
|
||||
nonHandledCompletions.forEach((completion) => {
|
||||
expect(getMentionDisplayText(completion, mockClient)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the completion if we are handling a user", () => {
|
||||
const testCompletion = "display this";
|
||||
const userCompletion = createMockCompletion({ type: "user", completion: testCompletion });
|
||||
|
||||
expect(getMentionDisplayText(userCompletion, mockClient)).toBe(testCompletion);
|
||||
});
|
||||
|
||||
it("returns the room name when the room has a valid completionId", () => {
|
||||
const testCompletionId = "testId";
|
||||
const userCompletion = createMockCompletion({ type: "room", completionId: testCompletionId });
|
||||
|
||||
// as this uses the mockClient, the name will be the mock room name returned from there
|
||||
expect(getMentionDisplayText(userCompletion, mockClient)).toBe(mockClient.getRoom("")?.name);
|
||||
});
|
||||
|
||||
it("falls back to the completion for a room if completion starts with #", () => {
|
||||
const testCompletion = "#hash";
|
||||
const userCompletion = createMockCompletion({ type: "room", completion: testCompletion });
|
||||
|
||||
// as this uses the mockClient, the name will be the mock room name returned from there
|
||||
expect(getMentionDisplayText(userCompletion, mockClient)).toBe(testCompletion);
|
||||
});
|
||||
|
||||
it("returns the completion if we are handling an at-room completion", () => {
|
||||
const testCompletion = "display this";
|
||||
const atRoomCompletion = createMockCompletion({ type: "at-room", completion: testCompletion });
|
||||
|
||||
expect(getMentionDisplayText(atRoomCompletion, mockClient)).toBe(testCompletion);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMentionAttributes", () => {
|
||||
it("returns an empty map for completion types other than room, user or at-room", () => {
|
||||
const nonHandledCompletionTypes = ["community", "command"] as const;
|
||||
const nonHandledCompletions = nonHandledCompletionTypes.map((type) => createMockCompletion({ type }));
|
||||
|
||||
nonHandledCompletions.forEach((completion) => {
|
||||
expect(getMentionAttributes(completion, mockClient, mockRoom)).toEqual(new Map());
|
||||
});
|
||||
});
|
||||
|
||||
const testAvatarUrlForString = "www.stringUrl.com";
|
||||
const testAvatarUrlForMember = "www.memberUrl.com";
|
||||
const testAvatarUrlForRoom = "www.roomUrl.com";
|
||||
const testInitialLetter = "z";
|
||||
|
||||
const mockAvatar = mocked(_mockAvatar);
|
||||
mockAvatar.defaultAvatarUrlForString.mockReturnValue(testAvatarUrlForString);
|
||||
mockAvatar.avatarUrlForMember.mockReturnValue(testAvatarUrlForMember);
|
||||
mockAvatar.avatarUrlForRoom.mockReturnValue(testAvatarUrlForRoom);
|
||||
mockAvatar.getInitialLetter.mockReturnValue(testInitialLetter);
|
||||
|
||||
describe("user mentions", () => {
|
||||
it("returns an empty map when no member can be found", () => {
|
||||
const userCompletion = createMockCompletion({ type: "user" });
|
||||
|
||||
// mock not being able to find a member
|
||||
mockRoom.getMember.mockImplementationOnce(() => null);
|
||||
|
||||
const result = getMentionAttributes(userCompletion, mockClient, mockRoom);
|
||||
expect(result).toEqual(new Map());
|
||||
});
|
||||
|
||||
it("returns expected attributes when avatar url is not default", () => {
|
||||
const userCompletion = createMockCompletion({ type: "user" });
|
||||
|
||||
const result = getMentionAttributes(userCompletion, mockClient, mockRoom);
|
||||
|
||||
expect(result).toEqual(
|
||||
new Map([
|
||||
["data-mention-type", "user"],
|
||||
["style", `--avatar-background: url(${testAvatarUrlForMember}); --avatar-letter: '\u200b'`],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns expected style attributes when avatar url matches default", () => {
|
||||
const userCompletion = createMockCompletion({ type: "user" });
|
||||
|
||||
// mock a single implementation of avatarUrlForMember to make it match the default
|
||||
mockAvatar.avatarUrlForMember.mockReturnValueOnce(testAvatarUrlForString);
|
||||
|
||||
const result = getMentionAttributes(userCompletion, mockClient, mockRoom);
|
||||
|
||||
expect(result).toEqual(
|
||||
new Map([
|
||||
["data-mention-type", "user"],
|
||||
[
|
||||
"style",
|
||||
`--avatar-background: url(${testAvatarUrlForString}); --avatar-letter: '${testInitialLetter}'`,
|
||||
],
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("room mentions", () => {
|
||||
it("returns expected attributes when avatar url for room is truthy", () => {
|
||||
const userCompletion = createMockCompletion({ type: "room" });
|
||||
|
||||
const result = getMentionAttributes(userCompletion, mockClient, mockRoom);
|
||||
|
||||
expect(result).toEqual(
|
||||
new Map([
|
||||
["data-mention-type", "room"],
|
||||
["style", `--avatar-background: url(${testAvatarUrlForRoom}); --avatar-letter: '\u200b'`],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns expected style attributes when avatar url for room is falsy", () => {
|
||||
const userCompletion = createMockCompletion({ type: "room" });
|
||||
|
||||
// mock a single implementation of avatarUrlForRoom to make it falsy
|
||||
mockAvatar.avatarUrlForRoom.mockReturnValueOnce(null);
|
||||
|
||||
const result = getMentionAttributes(userCompletion, mockClient, mockRoom);
|
||||
|
||||
expect(result).toEqual(
|
||||
new Map([
|
||||
["data-mention-type", "room"],
|
||||
[
|
||||
"style",
|
||||
`--avatar-background: url(${testAvatarUrlForString}); --avatar-letter: '${testInitialLetter}'`,
|
||||
],
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("at-room mentions", () => {
|
||||
it("returns expected attributes when avatar url for room is truthyf", () => {
|
||||
const atRoomCompletion = createMockCompletion({ type: "at-room" });
|
||||
|
||||
const result = getMentionAttributes(atRoomCompletion, mockClient, mockRoom);
|
||||
|
||||
expect(result).toEqual(
|
||||
new Map([
|
||||
["data-mention-type", "at-room"],
|
||||
["style", `--avatar-background: url(${testAvatarUrlForRoom}); --avatar-letter: '\u200b'`],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns expected style attributes when avatar url for room is falsy", () => {
|
||||
const atRoomCompletion = createMockCompletion({ type: "at-room" });
|
||||
|
||||
// mock a single implementation of avatarUrlForRoom to make it falsy
|
||||
mockAvatar.avatarUrlForRoom.mockReturnValueOnce(null);
|
||||
|
||||
const result = getMentionAttributes(atRoomCompletion, mockClient, mockRoom);
|
||||
|
||||
expect(result).toEqual(
|
||||
new Map([
|
||||
["data-mention-type", "at-room"],
|
||||
[
|
||||
"style",
|
||||
`--avatar-background: url(${testAvatarUrlForString}); --avatar-letter: '${testInitialLetter}'`,
|
||||
],
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,189 @@
|
|||
/*
|
||||
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 { MsgType } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { filterConsole, mkEvent } from "../../../../../../test-utils";
|
||||
import { RoomPermalinkCreator } from "../../../../../../../src/utils/permalinks/Permalinks";
|
||||
import {
|
||||
createMessageContent,
|
||||
EMOTE_PREFIX,
|
||||
} from "../../../../../../../src/components/views/rooms/wysiwyg_composer/utils/createMessageContent";
|
||||
|
||||
describe("createMessageContent", () => {
|
||||
const permalinkCreator = {
|
||||
forEvent(eventId: string): string {
|
||||
return "$$permalink$$";
|
||||
},
|
||||
} as RoomPermalinkCreator;
|
||||
const message = "<em><b>hello</b> world</em>";
|
||||
const mockEvent = mkEvent({
|
||||
type: "m.room.message",
|
||||
room: "myfakeroom",
|
||||
user: "myfakeuser",
|
||||
content: { msgtype: "m.text", body: "Replying to this" },
|
||||
event: true,
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("Richtext composer input", () => {
|
||||
filterConsole(
|
||||
"WebAssembly.instantiateStreaming` failed because your server does not serve wasm with `application/wasm`",
|
||||
);
|
||||
|
||||
beforeAll(async () => {
|
||||
// Warm up by creating the component once, with a long timeout.
|
||||
// This prevents tests timing out because of the time spent loading
|
||||
// the WASM component.
|
||||
await createMessageContent(message, true, { permalinkCreator });
|
||||
}, 10000);
|
||||
|
||||
it("Should create html message", async () => {
|
||||
// When
|
||||
const content = await createMessageContent(message, true, { permalinkCreator });
|
||||
|
||||
// Then
|
||||
expect(content).toEqual({
|
||||
body: "*__hello__ world*",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: message,
|
||||
msgtype: "m.text",
|
||||
});
|
||||
});
|
||||
|
||||
it("Should add reply to message content", async () => {
|
||||
// When
|
||||
const content = await createMessageContent(message, true, { permalinkCreator, replyToEvent: mockEvent });
|
||||
|
||||
// Then
|
||||
expect(content).toEqual({
|
||||
"body": "> <myfakeuser> Replying to this\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/#/myfakeuser">myfakeuser</a>' +
|
||||
"<br>Replying to this</blockquote></mx-reply><em><b>hello</b> world</em>",
|
||||
"msgtype": "m.text",
|
||||
"m.relates_to": {
|
||||
"m.in_reply_to": {
|
||||
event_id: mockEvent.getId(),
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("Should add relation to message", async () => {
|
||||
// When
|
||||
const relation = {
|
||||
rel_type: "m.thread",
|
||||
event_id: "myFakeThreadId",
|
||||
};
|
||||
const content = await createMessageContent(message, true, { permalinkCreator, relation });
|
||||
|
||||
// Then
|
||||
expect(content).toEqual({
|
||||
"body": "*__hello__ world*",
|
||||
"format": "org.matrix.custom.html",
|
||||
"formatted_body": message,
|
||||
"msgtype": "m.text",
|
||||
"m.relates_to": {
|
||||
event_id: "myFakeThreadId",
|
||||
rel_type: "m.thread",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("Should add fields related to edition", async () => {
|
||||
// When
|
||||
const editedEvent = mkEvent({
|
||||
type: "m.room.message",
|
||||
room: "myfakeroom",
|
||||
user: "myfakeuser2",
|
||||
content: {
|
||||
"msgtype": "m.text",
|
||||
"body": "First message",
|
||||
"formatted_body": "<b>First Message</b>",
|
||||
"m.relates_to": {
|
||||
"m.in_reply_to": {
|
||||
event_id: "eventId",
|
||||
},
|
||||
},
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
const content = await createMessageContent(message, true, { permalinkCreator, editedEvent });
|
||||
|
||||
// Then
|
||||
expect(content).toEqual({
|
||||
"body": " * *__hello__ world*",
|
||||
"format": "org.matrix.custom.html",
|
||||
"formatted_body": ` * ${message}`,
|
||||
"msgtype": "m.text",
|
||||
"m.new_content": {
|
||||
body: "*__hello__ world*",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: message,
|
||||
msgtype: "m.text",
|
||||
},
|
||||
"m.relates_to": {
|
||||
event_id: editedEvent.getId(),
|
||||
rel_type: "m.replace",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("Should strip the /me prefix from a message", async () => {
|
||||
const textBody = "some body text";
|
||||
const content = await createMessageContent(EMOTE_PREFIX + textBody, true, { permalinkCreator });
|
||||
|
||||
expect(content).toMatchObject({ body: textBody, formatted_body: textBody });
|
||||
});
|
||||
|
||||
it("Should strip single / from message prefixed with //", async () => {
|
||||
const content = await createMessageContent("//twoSlashes", true, { permalinkCreator });
|
||||
|
||||
expect(content).toMatchObject({ body: "/twoSlashes", formatted_body: "/twoSlashes" });
|
||||
});
|
||||
|
||||
it("Should set the content type to MsgType.Emote when /me prefix is used", async () => {
|
||||
const textBody = "some body text";
|
||||
const content = await createMessageContent(EMOTE_PREFIX + textBody, true, { permalinkCreator });
|
||||
|
||||
expect(content).toMatchObject({ msgtype: MsgType.Emote });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Plaintext composer input", () => {
|
||||
it("Should replace at-room mentions with `@room` in body", async () => {
|
||||
const messageComposerState = `<a href="#" contenteditable="false" data-mention-type="at-room" style="some styling">@room</a> `;
|
||||
|
||||
const content = await createMessageContent(messageComposerState, false, { permalinkCreator });
|
||||
expect(content).toMatchObject({ body: "@room " });
|
||||
});
|
||||
|
||||
it("Should replace user mentions with user name in body", async () => {
|
||||
const messageComposerState = `<a href="https://matrix.to/#/@test_user:element.io" contenteditable="false" data-mention-type="user" style="some styling">a test user</a> `;
|
||||
|
||||
const content = await createMessageContent(messageComposerState, false, { permalinkCreator });
|
||||
|
||||
expect(content).toMatchObject({ body: "a test user " });
|
||||
});
|
||||
|
||||
it("Should replace room mentions with room mxid in body", async () => {
|
||||
const messageComposerState = `<a href="https://matrix.to/#/#test_room:element.io" contenteditable="false" data-mention-type="room" style="some styling">a test room</a> `;
|
||||
|
||||
const content = await createMessageContent(messageComposerState, false, { permalinkCreator });
|
||||
|
||||
expect(content).toMatchObject({
|
||||
body: "#test_room:element.io ",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,466 @@
|
|||
/*
|
||||
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" });
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue