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:
Michael Telatynski 2024-10-15 14:57:26 +01:00
commit f0ee7f7905
No known key found for this signature in database
GPG key ID: A2B008A5F49F5D0D
3265 changed files with 484599 additions and 699 deletions

View 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");
});
});

View file

@ -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: () => {},
};
}

View file

@ -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()],
});
});
});
});

View 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();
});
});
});
});

View file

@ -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);
});
});

View file

@ -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>
`;

View 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);
});
});

View 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,
});
});
});
});
});

View 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();
});
});
});

View 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,
};
}

View file

@ -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"],
]);
});
});
});

View 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, youll 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);
});
});
});

View file

@ -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();
});
});
});

View file

@ -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();
});
});

View file

@ -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();
});
});

View 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,
});
});
});

View file

@ -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();
});
});
});

View file

@ -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>
`);
});
});

View 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, weve 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,
}),
);
});
});
});

View file

@ -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);
});
});

View 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);
}

View file

@ -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);
});
});
});

View file

@ -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(),
);
});
});

View file

@ -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>
`;

View 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`);
});
});
});
});

View 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();
});
});
});
});

View 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]);
});
});
});

View 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();
});
});
});

View file

@ -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();
});
});

View file

@ -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();
});
});

View 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();
});
});
});
});

View file

@ -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);
});
});

View file

@ -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();
});
});

View file

@ -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();
});
});

View file

@ -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"] },
});
});
});
});

View file

@ -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>
`;

View file

@ -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>
`;

View file

@ -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>
`;

View file

@ -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>
`;

View file

@ -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>
`;

View file

@ -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>
`;

View file

@ -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>
`;

View file

@ -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>
`;

View file

@ -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>
`;

View file

@ -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);
});
});

View file

@ -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/));
});
},
);
});

View file

@ -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();
});
});

View file

@ -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());
});
});

View file

@ -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");
});
});

View file

@ -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());
});
});

View file

@ -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,
}),
);
});
});
});
});
});

View file

@ -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>
`;

View file

@ -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);
});
});

View file

@ -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: "🙂",
});
});
});

View file

@ -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);
});
});

View file

@ -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 };
}

View file

@ -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}'`,
],
]),
);
});
});
});

View file

@ -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 ",
});
});
});
});

View file

@ -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" });
});
});
});