Prepare for repo merge

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski 2024-10-15 11:35:21 +01:00
parent 0f670b8dc0
commit b084ff2313
No known key found for this signature in database
GPG key ID: A2B008A5F49F5D0D
807 changed files with 0 additions and 0 deletions

View file

@ -0,0 +1,156 @@
/*
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, cleanup, fireEvent, waitFor } from "jest-matrix-react";
import { mocked, Mocked } from "jest-mock";
import { Room, RoomStateEvent, MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/matrix";
import { ClientWidgetApi, Widget } from "matrix-widget-api";
import type { RoomMember } from "matrix-js-sdk/src/matrix";
import {
useMockedCalls,
MockedCall,
stubClient,
mkRoomMember,
setupAsyncStoreWithClient,
resetAsyncStoreWithClient,
wrapInMatrixClientContext,
useMockMediaDevices,
} from "../../../test-utils";
import defaultDispatcher from "../../../../src/dispatcher/dispatcher";
import { Action } from "../../../../src/dispatcher/actions";
import { CallEvent as UnwrappedCallEvent } from "../../../../src/components/views/messages/CallEvent";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import { CallStore } from "../../../../src/stores/CallStore";
import { WidgetMessagingStore } from "../../../../src/stores/widgets/WidgetMessagingStore";
import { ConnectionState } from "../../../../src/models/Call";
const CallEvent = wrapInMatrixClientContext(UnwrappedCallEvent);
describe("CallEvent", () => {
let client: Mocked<MatrixClient>;
let room: Room;
let alice: RoomMember;
let bob: RoomMember;
let call: MockedCall;
let widget: Widget;
beforeEach(async () => {
jest.useFakeTimers();
jest.setSystemTime(0);
useMockMediaDevices();
useMockedCalls();
jest.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(async () => {});
stubClient();
client = mocked(MatrixClientPeg.safeGet());
client.getUserId.mockReturnValue("@alice:example.org");
room = new Room("!1:example.org", client, "@alice:example.org", {
pendingEventOrdering: PendingEventOrdering.Detached,
});
alice = mkRoomMember(room.roomId, "@alice:example.org");
bob = mkRoomMember(room.roomId, "@bob:example.org");
jest.spyOn(room, "getMember").mockImplementation(
(userId) => [alice, bob].find((member) => member.userId === userId) ?? null,
);
client.getRoom.mockImplementation((roomId) => (roomId === room.roomId ? room : null));
client.getRooms.mockReturnValue([room]);
client.reEmitter.reEmit(room, [RoomStateEvent.Events]);
await Promise.all(
[CallStore.instance, WidgetMessagingStore.instance].map((store) =>
setupAsyncStoreWithClient(store, 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(async () => {
cleanup(); // Unmount before we do any cleanup that might update the component
call.destroy();
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
await Promise.all([CallStore.instance, WidgetMessagingStore.instance].map(resetAsyncStoreWithClient));
client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]);
jest.restoreAllMocks();
});
const renderEvent = () => {
render(<CallEvent mxEvent={call.event} />);
};
it("shows a message and duration if the call was ended", () => {
jest.advanceTimersByTime(90000);
call.destroy();
renderEvent();
screen.getByText("Video call ended");
screen.getByText("1m 30s");
});
it("shows a message if the call was redacted", () => {
const event = room.currentState.getStateEvents(MockedCall.EVENT_TYPE, "1")!;
jest.spyOn(event, "isRedacted").mockReturnValue(true);
renderEvent();
screen.getByText("Video call ended");
});
it("shows placeholder info if the call isn't loaded yet", () => {
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(null);
jest.advanceTimersByTime(90000);
renderEvent();
screen.getByText("@alice:example.org started a video call");
expect(screen.getByRole("button", { name: "Join" })).toHaveAttribute("aria-disabled", "true");
});
it("shows call details and connection controls if the call is loaded", async () => {
jest.advanceTimersByTime(90000);
call.participants = new Map([
[alice, new Set(["a"])],
[bob, new Set(["b"])],
]);
renderEvent();
screen.getByText("@alice:example.org started a video call");
screen.getByLabelText("2 people joined");
// Test that the join button works
const dispatcherSpy = jest.fn();
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
fireEvent.click(screen.getByRole("button", { name: "Join" }));
await waitFor(() =>
expect(dispatcherSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: room.roomId,
view_call: true,
}),
);
defaultDispatcher.unregister(dispatcherRef);
await act(() => call.start());
// Test that the leave button works
fireEvent.click(screen.getByRole("button", { name: "Leave" }));
await waitFor(() => screen.getByRole("button", { name: "Join" }));
expect(call.connectionState).toBe(ConnectionState.Disconnected);
});
});

View file

@ -0,0 +1,298 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 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 } from "jest-mock";
import { fireEvent, render, screen } from "jest-matrix-react";
import { TimestampToEventResponse, ConnectionError, HTTPError, MatrixError } from "matrix-js-sdk/src/matrix";
import dispatcher from "../../../../src/dispatcher/dispatcher";
import { Action } from "../../../../src/dispatcher/actions";
import { ViewRoomPayload } from "../../../../src/dispatcher/payloads/ViewRoomPayload";
import { SdkContextClass } from "../../../../src/contexts/SDKContext";
import { formatFullDateNoTime } from "../../../../src/DateUtils";
import SettingsStore from "../../../../src/settings/SettingsStore";
import { UIFeature } from "../../../../src/settings/UIFeature";
import MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
import {
clearAllModals,
flushPromisesWithFakeTimers,
getMockClientWithEventEmitter,
waitEnoughCyclesForModal,
} from "../../../test-utils";
import DateSeparator from "../../../../src/components/views/messages/DateSeparator";
jest.mock("../../../../src/settings/SettingsStore");
describe("DateSeparator", () => {
const HOUR_MS = 3600000;
const DAY_MS = HOUR_MS * 24;
// Friday Dec 17 2021, 9:09am
const nowDate = new Date("2021-12-17T08:09:00.000Z");
const roomId = "!unused:example.org";
const defaultProps = {
ts: nowDate.getTime(),
roomId,
};
const mockClient = getMockClientWithEventEmitter({
timestampToEvent: jest.fn(),
});
const getComponent = (props = {}) =>
render(
<MatrixClientContext.Provider value={mockClient}>
<DateSeparator {...defaultProps} {...props} />
</MatrixClientContext.Provider>,
);
type TestCase = [string, number, string];
const testCases: TestCase[] = [
["the exact same moment", nowDate.getTime(), "today"],
["same day as current day", nowDate.getTime() - HOUR_MS, "today"],
["day before the current day", nowDate.getTime() - HOUR_MS * 12, "yesterday"],
["2 days ago", nowDate.getTime() - DAY_MS * 2, "Wednesday"],
["144 hours ago", nowDate.getTime() - HOUR_MS * 144, "Sat, Dec 11, 2021"],
[
"6 days ago, but less than 144h",
new Date("Saturday Dec 11 2021 23:59:00 GMT+0100 (Central European Standard Time)").getTime(),
"Saturday",
],
];
beforeEach(() => {
// Set a consistent fake time here so the test is always consistent
jest.useFakeTimers();
jest.setSystemTime(nowDate.getTime());
(SettingsStore.getValue as jest.Mock) = jest.fn((arg) => {
if (arg === UIFeature.TimelineEnableRelativeDates) {
return true;
}
});
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue(roomId);
});
afterAll(() => {
jest.useRealTimers();
});
it("renders the date separator correctly", () => {
const { asFragment } = getComponent();
expect(asFragment()).toMatchSnapshot();
expect(SettingsStore.getValue).toHaveBeenCalledWith(UIFeature.TimelineEnableRelativeDates);
});
it.each(testCases)("formats date correctly when current time is %s", (_d, ts, result) => {
expect(getComponent({ ts, forExport: false }).container.textContent).toEqual(result);
});
describe("when forExport is true", () => {
it.each(testCases)("formats date in full when current time is %s", (_d, ts) => {
expect(getComponent({ ts, forExport: true }).container.textContent).toEqual(
formatFullDateNoTime(new Date(ts)),
);
});
});
describe("when Settings.TimelineEnableRelativeDates is falsy", () => {
beforeEach(() => {
(SettingsStore.getValue as jest.Mock) = jest.fn((arg) => {
if (arg === UIFeature.TimelineEnableRelativeDates) {
return false;
}
});
});
it.each(testCases)("formats date in full when current time is %s", (_d, ts) => {
expect(getComponent({ ts, forExport: false }).container.textContent).toEqual(
formatFullDateNoTime(new Date(ts)),
);
});
});
describe("when feature_jump_to_date is enabled", () => {
beforeEach(() => {
jest.clearAllMocks();
mocked(SettingsStore).getValue.mockImplementation((arg): any => {
if (arg === "feature_jump_to_date") {
return true;
}
});
jest.spyOn(dispatcher, "dispatch").mockImplementation(() => {});
});
afterEach(async () => {
await clearAllModals();
});
it("renders the date separator correctly", () => {
const { asFragment } = getComponent();
expect(asFragment()).toMatchSnapshot();
});
[
{
timeDescriptor: "last week",
jumpButtonTestId: "jump-to-date-last-week",
},
{
timeDescriptor: "last month",
jumpButtonTestId: "jump-to-date-last-month",
},
{
timeDescriptor: "the beginning",
jumpButtonTestId: "jump-to-date-beginning",
},
].forEach((testCase) => {
it(`can jump to ${testCase.timeDescriptor}`, async () => {
// Render the component
getComponent();
// Open the jump to date context menu
fireEvent.click(screen.getByTestId("jump-to-date-separator-button"));
// Jump to "x"
const returnedDate = new Date();
// Just an arbitrary date before "now"
returnedDate.setDate(nowDate.getDate() - 100);
const returnedEventId = "$abc";
mockClient.timestampToEvent.mockResolvedValue({
event_id: returnedEventId,
origin_server_ts: returnedDate.getTime(),
} satisfies TimestampToEventResponse);
const jumpToXButton = await screen.findByTestId(testCase.jumpButtonTestId);
fireEvent.click(jumpToXButton);
// Flush out the dispatcher which uses `window.setTimeout(...)` since we're
// using `jest.useFakeTimers()` in these tests.
await flushPromisesWithFakeTimers();
// Ensure that we're jumping to the event at the given date
expect(dispatcher.dispatch).toHaveBeenCalledWith({
action: Action.ViewRoom,
event_id: returnedEventId,
highlighted: true,
room_id: roomId,
metricsTrigger: undefined,
} satisfies ViewRoomPayload);
});
});
it("should not jump to date if we already switched to another room", async () => {
// Render the component
getComponent();
// Open the jump to date context menu
fireEvent.click(screen.getByTestId("jump-to-date-separator-button"));
// Mimic the outcome of switching rooms while waiting for the jump to date
// request to finish. Imagine that we started jumping to "last week", the
// network request is taking a while, so we got bored, switched rooms; we
// shouldn't jump back to the previous room after the network request
// happens to finish later.
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!some-other-room");
// Jump to "last week"
mockClient.timestampToEvent.mockResolvedValue({
event_id: "$abc",
origin_server_ts: 0,
});
const jumpToLastWeekButton = await screen.findByTestId("jump-to-date-last-week");
fireEvent.click(jumpToLastWeekButton);
// Flush out the dispatcher which uses `window.setTimeout(...)` since we're
// using `jest.useFakeTimers()` in these tests.
await flushPromisesWithFakeTimers();
// We should not see any room switching going on (`Action.ViewRoom`)
expect(dispatcher.dispatch).not.toHaveBeenCalled();
});
it("should not show jump to date error if we already switched to another room", async () => {
// Render the component
getComponent();
// Open the jump to date context menu
fireEvent.click(screen.getByTestId("jump-to-date-separator-button"));
// Mimic the outcome of switching rooms while waiting for the jump to date
// request to finish. Imagine that we started jumping to "last week", the
// network request is taking a while, so we got bored, switched rooms; we
// shouldn't jump back to the previous room after the network request
// happens to finish later.
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!some-other-room");
// Try to jump to "last week" but we want an error to occur and ensure that
// we don't show an error dialog for it since we already switched away to
// another room and don't care about the outcome here anymore.
mockClient.timestampToEvent.mockRejectedValue(new Error("Fake error in test"));
const jumpToLastWeekButton = await screen.findByTestId("jump-to-date-last-week");
fireEvent.click(jumpToLastWeekButton);
// Wait the necessary time in order to see if any modal will appear
await waitEnoughCyclesForModal({
useFakeTimers: true,
});
// We should not see any error modal dialog
//
// We have to use `queryBy` so that it can return `null` for something that does not exist.
expect(screen.queryByTestId("jump-to-date-error-content")).not.toBeInTheDocument();
});
it("should show error dialog with submit debug logs option when non-networking error occurs", async () => {
// Render the component
getComponent();
// Open the jump to date context menu
fireEvent.click(screen.getByTestId("jump-to-date-separator-button"));
// Try to jump to "last week" but we want a non-network error to occur so it
// shows the "Submit debug logs" UI
mockClient.timestampToEvent.mockRejectedValue(new Error("Fake error in test"));
const jumpToLastWeekButton = await screen.findByTestId("jump-to-date-last-week");
fireEvent.click(jumpToLastWeekButton);
// Expect error to be shown. We have to wait for the UI to transition.
expect(await screen.findByTestId("jump-to-date-error-content")).toBeInTheDocument();
// Expect an option to submit debug logs to be shown when a non-network error occurs
expect(await screen.findByTestId("jump-to-date-error-submit-debug-logs-button")).toBeInTheDocument();
});
[
new ConnectionError("Fake connection error in test"),
new HTTPError("Fake http error in test", 418),
new MatrixError(
{ errcode: "M_FAKE_ERROR_CODE", error: "Some fake error occured" },
518,
"https://fake-url/",
),
].forEach((fakeError) => {
it(`should show error dialog without submit debug logs option when networking error (${fakeError.name}) occurs`, async () => {
// Render the component
getComponent();
// Open the jump to date context menu
fireEvent.click(screen.getByTestId("jump-to-date-separator-button"));
// Try to jump to "last week" but we want a network error to occur
mockClient.timestampToEvent.mockRejectedValue(fakeError);
const jumpToLastWeekButton = await screen.findByTestId("jump-to-date-last-week");
fireEvent.click(jumpToLastWeekButton);
// Expect error to be shown. We have to wait for the UI to transition.
expect(await screen.findByTestId("jump-to-date-error-content")).toBeInTheDocument();
// The submit debug logs option should *NOT* be shown for network errors.
//
// We have to use `queryBy` so that it can return `null` for something that does not exist.
expect(screen.queryByTestId("jump-to-date-error-submit-debug-logs-button")).not.toBeInTheDocument();
});
});
});
});

View file

@ -0,0 +1,134 @@
/*
* 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 { MatrixEvent } from "matrix-js-sdk/src/matrix";
import { mkDecryptionFailureMatrixEvent } from "matrix-js-sdk/src/testing";
import { DecryptionFailureCode } from "matrix-js-sdk/src/crypto-api";
import { mkEvent } from "../../../test-utils";
import { DecryptionFailureBody } from "../../../../src/components/views/messages/DecryptionFailureBody";
import { LocalDeviceVerificationStateContext } from "../../../../src/contexts/LocalDeviceVerificationStateContext";
describe("DecryptionFailureBody", () => {
function customRender(event: MatrixEvent, localDeviceVerified: boolean = false) {
return render(
<LocalDeviceVerificationStateContext.Provider value={localDeviceVerified}>
<DecryptionFailureBody mxEvent={event} />
</LocalDeviceVerificationStateContext.Provider>,
);
}
it(`Should display "Unable to decrypt message"`, () => {
// When
const event = mkEvent({
type: "m.room.message",
room: "myfakeroom",
user: "myfakeuser",
content: {
msgtype: "m.bad.encrypted",
},
event: true,
});
const { container } = customRender(event);
// Then
expect(container).toMatchSnapshot();
});
it(`Should display "The sender has blocked you from receiving this message"`, async () => {
// When
const event = await mkDecryptionFailureMatrixEvent({
code: DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE,
msg: "withheld",
roomId: "myfakeroom",
sender: "myfakeuser",
});
const { container } = customRender(event);
// Then
expect(container).toMatchSnapshot();
});
it("should handle historical messages with no key backup", async () => {
// When
const event = await mkDecryptionFailureMatrixEvent({
code: DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP,
msg: "No backup",
roomId: "fakeroom",
sender: "fakesender",
});
const { container } = customRender(event);
// Then
expect(container).toHaveTextContent("Historical messages are not available on this device");
});
it.each([true, false])(
"should handle historical messages when there is a backup and device verification is %s",
async (verified) => {
// When
const event = await mkDecryptionFailureMatrixEvent({
code: DecryptionFailureCode.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED,
msg: "Failure",
roomId: "fakeroom",
sender: "fakesender",
});
const { container } = customRender(event, verified);
// Then
expect(container).toHaveTextContent(
verified ? "Unable to decrypt" : "You need to verify this device for access to historical messages",
);
},
);
it("should handle undecryptable pre-join messages", async () => {
// When
const event = await mkDecryptionFailureMatrixEvent({
code: DecryptionFailureCode.HISTORICAL_MESSAGE_USER_NOT_JOINED,
msg: "Not joined",
roomId: "fakeroom",
sender: "fakesender",
});
const { container } = customRender(event);
// Then
expect(container).toHaveTextContent("You don't have access to this message");
});
it("should handle messages from users who change identities after verification", async () => {
// When
const event = await mkDecryptionFailureMatrixEvent({
code: DecryptionFailureCode.SENDER_IDENTITY_PREVIOUSLY_VERIFIED,
msg: "User previously verified",
roomId: "fakeroom",
sender: "fakesender",
});
const { container } = customRender(event);
// Then
expect(container).toMatchSnapshot();
});
it("should handle messages from unverified devices", async () => {
// When
const event = await mkDecryptionFailureMatrixEvent({
code: DecryptionFailureCode.UNSIGNED_SENDER_DEVICE,
msg: "Unsigned device",
roomId: "fakeroom",
sender: "fakesender",
});
const { container } = customRender(event);
// Then
expect(container).toHaveTextContent("Encrypted by a device not verified by its owner");
});
});

View file

@ -0,0 +1,60 @@
/*
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 { mocked } from "jest-mock";
import fetchMockJest from "fetch-mock-jest";
import { fireEvent, render, screen, waitFor } from "jest-matrix-react";
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import { stubClient } from "../../../test-utils";
import DownloadActionButton from "../../../../src/components/views/messages/DownloadActionButton";
import Modal from "../../../../src/Modal";
import { MediaEventHelper } from "../../../../src/utils/MediaEventHelper";
import ErrorDialog from "../../../../src/components/views/dialogs/ErrorDialog";
describe("DownloadActionButton", () => {
it("should show error if media API returns one", async () => {
const cli = stubClient();
// eslint-disable-next-line no-restricted-properties
mocked(cli.mxcUrlToHttp).mockImplementation(
(mxc) => `https://matrix.org/_matrix/media/r0/download/${mxc.slice(6)}`,
);
fetchMockJest.get("https://matrix.org/_matrix/media/r0/download/matrix.org/1234", {
status: 404,
body: { errcode: "M_NOT_FOUND", error: "Not found" },
});
const event = new MatrixEvent({
room_id: "!room:id",
sender: "@user:id",
type: "m.room.message",
content: {
body: "test",
msgtype: "m.image",
url: "mxc://matrix.org/1234",
},
});
const mediaEventHelper = new MediaEventHelper(event);
render(<DownloadActionButton mxEvent={event} mediaEventHelperGet={() => mediaEventHelper} />);
const spy = jest.spyOn(Modal, "createDialog");
fireEvent.click(screen.getByRole("button"));
await waitFor(() =>
expect(spy).toHaveBeenCalledWith(
ErrorDialog,
expect.objectContaining({
title: "Download failed",
}),
),
);
});
});

View file

@ -0,0 +1,123 @@
/*
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 } from "jest-mock";
import { MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import { render, screen } from "jest-matrix-react";
import EncryptionEvent from "../../../../src/components/views/messages/EncryptionEvent";
import { createTestClient, mkMessage } from "../../../test-utils";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import { LocalRoom } from "../../../../src/models/LocalRoom";
import DMRoomMap from "../../../../src/utils/DMRoomMap";
import MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
const renderEncryptionEvent = (client: MatrixClient, event: MatrixEvent) => {
render(
<MatrixClientContext.Provider value={client}>
<EncryptionEvent mxEvent={event} />
</MatrixClientContext.Provider>,
);
};
const checkTexts = (title: string, subTitle: string) => {
screen.getByText(title);
screen.getByText(subTitle);
};
describe("EncryptionEvent", () => {
const roomId = "!room:example.com";
const algorithm = "m.megolm.v1.aes-sha2";
let client: MatrixClient;
let event: MatrixEvent;
beforeEach(() => {
jest.clearAllMocks();
client = createTestClient();
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(client);
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(client);
event = mkMessage({
event: true,
room: roomId,
user: client.getUserId()!,
});
jest.spyOn(DMRoomMap, "shared").mockReturnValue({
getUserIdForRoomId: jest.fn(),
} as unknown as DMRoomMap);
});
describe("for an encrypted room", () => {
beforeEach(() => {
event.event.content!.algorithm = algorithm;
mocked(client.isRoomEncrypted).mockReturnValue(true);
const room = new Room(roomId, client, client.getUserId()!);
mocked(client.getRoom).mockReturnValue(room);
});
it("should show the expected texts", () => {
renderEncryptionEvent(client, event);
checkTexts(
"Encryption enabled",
"Messages in this room are end-to-end encrypted. " +
"When people join, you can verify them in their profile, just tap on their profile picture.",
);
});
describe("with same previous algorithm", () => {
beforeEach(() => {
jest.spyOn(event, "getPrevContent").mockReturnValue({
algorithm: algorithm,
});
});
it("should show the expected texts", () => {
renderEncryptionEvent(client, event);
checkTexts("Encryption enabled", "Some encryption parameters have been changed.");
});
});
describe("with unknown algorithm", () => {
beforeEach(() => {
event.event.content!.algorithm = "unknown";
});
it("should show the expected texts", () => {
renderEncryptionEvent(client, event);
checkTexts("Encryption enabled", "Ignored attempt to disable encryption");
});
});
});
describe("for an unencrypted room", () => {
beforeEach(() => {
mocked(client.isRoomEncrypted).mockReturnValue(false);
renderEncryptionEvent(client, event);
});
it("should show the expected texts", () => {
expect(client.isRoomEncrypted).toHaveBeenCalledWith(roomId);
checkTexts("Encryption not enabled", "The encryption used by this room isn't supported.");
});
});
describe("for an encrypted local room", () => {
beforeEach(() => {
event.event.content!.algorithm = algorithm;
mocked(client.isRoomEncrypted).mockReturnValue(true);
const localRoom = new LocalRoom(roomId, client, client.getUserId()!);
mocked(client.getRoom).mockReturnValue(localRoom);
renderEncryptionEvent(client, event);
});
it("should show the expected texts", () => {
expect(client.isRoomEncrypted).toHaveBeenCalledWith(roomId);
checkTexts("Encryption enabled", "Messages in this chat will be end-to-end encrypted.");
});
});
});

View file

@ -0,0 +1,33 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 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 JumpToDatePicker from "../../../../src/components/views/messages/JumpToDatePicker";
describe("JumpToDatePicker", () => {
const nowDate = new Date("2021-12-17T08:09:00.000Z");
beforeEach(() => {
// Set a stable fake time here so the test is always consistent
jest.useFakeTimers();
jest.setSystemTime(nowDate.getTime());
});
afterAll(() => {
jest.useRealTimers();
});
it("renders the date picker correctly", () => {
const { asFragment } = render(
<JumpToDatePicker ts={new Date("2020-07-04T05:55:00.000Z").getTime()} onDatePicked={() => {}} />,
);
expect(asFragment()).toMatchSnapshot();
});
});

View file

@ -0,0 +1,97 @@
/*
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 { CallErrorCode, CallState } from "matrix-js-sdk/src/webrtc/call";
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import LegacyCallEvent from "../../../../src/components/views/messages/LegacyCallEvent";
import LegacyCallEventGrouper from "../../../../src/components/structures/LegacyCallEventGrouper";
const THEIR_USER_ID = "@them:here";
describe("LegacyCallEvent", () => {
let callInviteEvent: Record<string, any>;
let callEventGrouper: Record<string, any>;
beforeEach(() => {
callInviteEvent = {
sender: {
userId: THEIR_USER_ID,
},
};
callEventGrouper = {
addListener: jest.fn(),
removeListener: jest.fn(),
invite: jest.fn().mockReturnValue(callInviteEvent),
};
});
const renderEvent = () => {
render(
<LegacyCallEvent
mxEvent={callInviteEvent as unknown as MatrixEvent}
callEventGrouper={callEventGrouper as unknown as LegacyCallEventGrouper}
/>,
);
};
it("shows if the call was ended", () => {
callEventGrouper.state = CallState.Ended;
callEventGrouper.gotRejected = jest.fn().mockReturnValue(true);
renderEvent();
screen.getByText("Call declined");
});
it("shows if the call was answered elsewhere", () => {
callEventGrouper.state = CallState.Ended;
callEventGrouper.hangupReason = CallErrorCode.AnsweredElsewhere;
renderEvent();
screen.getByText("Answered elsewhere");
});
it("shows if the call was missed", () => {
callEventGrouper.state = CallState.Ended;
callEventGrouper.callWasMissed = jest.fn().mockReturnValue(true);
renderEvent();
screen.getByText("Missed call");
});
it("shows if the call ended cleanly", () => {
callEventGrouper.state = CallState.Ended;
callEventGrouper.hangupReason = CallErrorCode.UserHangup;
renderEvent();
screen.getByText("Call ended");
});
it("shows if the call is connecting", () => {
callEventGrouper.state = CallState.Connecting;
renderEvent();
screen.getByText("Connecting");
});
it("shows timer if the call is connected", () => {
callEventGrouper.state = CallState.Connected;
renderEvent();
screen.getByText("00:00");
});
});

View file

@ -0,0 +1,416 @@
/*
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 { act, fireEvent, render } from "jest-matrix-react";
import * as maplibregl from "maplibre-gl";
import {
BeaconEvent,
getBeaconInfoIdentifier,
RelationType,
MatrixEvent,
EventType,
Relations,
M_BEACON,
Room,
} from "matrix-js-sdk/src/matrix";
import MBeaconBody from "../../../../src/components/views/messages/MBeaconBody";
import {
getMockClientWithEventEmitter,
makeBeaconEvent,
makeBeaconInfoEvent,
makeRoomWithBeacons,
makeRoomWithStateEvents,
} from "../../../test-utils";
import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks";
import { MediaEventHelper } from "../../../../src/utils/MediaEventHelper";
import MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
import Modal from "../../../../src/Modal";
import { TILE_SERVER_WK_KEY } from "../../../../src/utils/WellKnownUtils";
import * as mapUtilHooks from "../../../../src/utils/location/useMap";
import { LocationShareError } from "../../../../src/utils/location";
describe("<MBeaconBody />", () => {
// 14.03.2022 16:15
const now = 1647270879403;
// stable date for snapshots
jest.spyOn(global.Date, "now").mockReturnValue(now);
const roomId = "!room:server";
const aliceId = "@alice:server";
const mapOptions = { container: {} as unknown as HTMLElement, style: "" };
const mockMap = new maplibregl.Map(mapOptions);
const mockMarker = new maplibregl.Marker();
const mockClient = getMockClientWithEventEmitter({
getClientWellKnown: jest.fn().mockReturnValue({
[TILE_SERVER_WK_KEY.name]: { map_style_url: "maps.com" },
}),
getUserId: jest.fn().mockReturnValue(aliceId),
getRoom: jest.fn(),
redactEvent: jest.fn(),
});
const defaultEvent = makeBeaconInfoEvent(aliceId, roomId, { isLive: true }, "$alice-room1-1");
const defaultProps: ComponentProps<typeof MBeaconBody> = {
mxEvent: defaultEvent,
highlights: [],
highlightLink: "",
onHeightChanged: jest.fn(),
onMessageAllowed: jest.fn(),
// we dont use these and they pollute the snapshots
permalinkCreator: {} as unknown as RoomPermalinkCreator,
mediaEventHelper: {} as unknown as MediaEventHelper,
};
const getComponent = (props = {}) =>
render(
<MatrixClientContext.Provider value={mockClient}>
<MBeaconBody {...defaultProps} {...props} />
</MatrixClientContext.Provider>,
);
const modalSpy = jest.spyOn(Modal, "createDialog").mockReturnValue({
finished: Promise.resolve([true]),
close: () => {},
});
beforeEach(() => {
jest.clearAllMocks();
});
const testBeaconStatuses = () => {
it("renders stopped beacon UI for an explicitly stopped beacon", () => {
const beaconInfoEvent = makeBeaconInfoEvent(aliceId, roomId, { isLive: false }, "$alice-room1-1");
makeRoomWithStateEvents([beaconInfoEvent], { roomId, mockClient });
const component = getComponent({ mxEvent: beaconInfoEvent });
expect(component.container).toHaveTextContent("Live location ended");
});
it("renders stopped beacon UI for an expired beacon", () => {
const beaconInfoEvent = makeBeaconInfoEvent(
aliceId,
roomId,
// puts this beacons live period in the past
{ isLive: true, timestamp: now - 600000, timeout: 500 },
"$alice-room1-1",
);
makeRoomWithStateEvents([beaconInfoEvent], { roomId, mockClient });
const component = getComponent({ mxEvent: beaconInfoEvent });
expect(component.container).toHaveTextContent("Live location ended");
});
it("renders loading beacon UI for a beacon that has not started yet", () => {
const beaconInfoEvent = makeBeaconInfoEvent(
aliceId,
roomId,
// puts this beacons start timestamp in the future
{ isLive: true, timestamp: now + 60000, timeout: 500 },
"$alice-room1-1",
);
makeRoomWithStateEvents([beaconInfoEvent], { roomId, mockClient });
const component = getComponent({ mxEvent: beaconInfoEvent });
expect(component.container).toHaveTextContent("Loading live location…");
});
it("does not open maximised map when on click when beacon is stopped", () => {
const beaconInfoEvent = makeBeaconInfoEvent(
aliceId,
roomId,
// puts this beacons live period in the past
{ isLive: true, timestamp: now - 600000, timeout: 500 },
"$alice-room1-1",
);
makeRoomWithStateEvents([beaconInfoEvent], { roomId, mockClient });
const component = getComponent({ mxEvent: beaconInfoEvent });
fireEvent.click(component.container.querySelector(".mx_MBeaconBody_map")!);
expect(modalSpy).not.toHaveBeenCalled();
});
it("renders stopped UI when a beacon event is not the latest beacon for a user", () => {
const aliceBeaconInfo1 = makeBeaconInfoEvent(
aliceId,
roomId,
// this one is a little older
{ isLive: true, timestamp: now - 500 },
"$alice-room1-1",
);
aliceBeaconInfo1.event.origin_server_ts = now - 500;
const aliceBeaconInfo2 = makeBeaconInfoEvent(aliceId, roomId, { isLive: true }, "$alice-room1-2");
makeRoomWithStateEvents([aliceBeaconInfo1, aliceBeaconInfo2], { roomId, mockClient });
const component = getComponent({ mxEvent: aliceBeaconInfo1 });
// beacon1 has been superceded by beacon2
expect(component.container).toHaveTextContent("Live location ended");
});
it("renders stopped UI when a beacon event is replaced", () => {
const aliceBeaconInfo1 = makeBeaconInfoEvent(
aliceId,
roomId,
// this one is a little older
{ isLive: true, timestamp: now - 500 },
"$alice-room1-1",
);
aliceBeaconInfo1.event.origin_server_ts = now - 500;
const aliceBeaconInfo2 = makeBeaconInfoEvent(aliceId, roomId, { isLive: true }, "$alice-room1-2");
const room = makeRoomWithStateEvents([aliceBeaconInfo1], { roomId, mockClient });
const component = getComponent({ mxEvent: aliceBeaconInfo1 });
const beaconInstance = room.currentState.beacons.get(getBeaconInfoIdentifier(aliceBeaconInfo1))!;
// update alice's beacon with a new edition
// beacon instance emits
act(() => {
beaconInstance.update(aliceBeaconInfo2);
});
// beacon1 has been superceded by beacon2
expect(component.container).toHaveTextContent("Live location ended");
});
};
testBeaconStatuses();
describe("on liveness change", () => {
it("renders stopped UI when a beacon stops being live", () => {
const aliceBeaconInfo = makeBeaconInfoEvent(aliceId, roomId, { isLive: true }, "$alice-room1-1");
const room = makeRoomWithStateEvents([aliceBeaconInfo], { roomId, mockClient });
const beaconInstance = room.currentState.beacons.get(getBeaconInfoIdentifier(aliceBeaconInfo))!;
const component = getComponent({ mxEvent: aliceBeaconInfo });
act(() => {
// @ts-ignore cheat to force beacon to not live
beaconInstance._isLive = false;
beaconInstance.emit(BeaconEvent.LivenessChange, false, beaconInstance);
});
// stopped UI
expect(component.container).toHaveTextContent("Live location ended");
});
});
describe("latestLocationState", () => {
const aliceBeaconInfo = makeBeaconInfoEvent(aliceId, roomId, { isLive: true }, "$alice-room1-1");
const location1 = makeBeaconEvent(aliceId, {
beaconInfoId: aliceBeaconInfo.getId(),
geoUri: "geo:51,41",
timestamp: now + 1,
});
const location2 = makeBeaconEvent(aliceId, {
beaconInfoId: aliceBeaconInfo.getId(),
geoUri: "geo:52,42",
timestamp: now + 10000,
});
it("renders a live beacon without a location correctly", () => {
makeRoomWithStateEvents([aliceBeaconInfo], { roomId, mockClient });
const component = getComponent({ mxEvent: aliceBeaconInfo });
expect(component.container).toHaveTextContent("Loading live location…");
});
it("does nothing on click when a beacon has no location", () => {
makeRoomWithStateEvents([aliceBeaconInfo], { roomId, mockClient });
const component = getComponent({ mxEvent: aliceBeaconInfo });
fireEvent.click(component.container.querySelector(".mx_MBeaconBody_map")!);
expect(modalSpy).not.toHaveBeenCalled();
});
it("renders a live beacon with a location correctly", () => {
const room = makeRoomWithStateEvents([aliceBeaconInfo], { roomId, mockClient });
const beaconInstance = room.currentState.beacons.get(getBeaconInfoIdentifier(aliceBeaconInfo))!;
beaconInstance.addLocations([location1]);
const component = getComponent({ mxEvent: aliceBeaconInfo });
expect(component.container.querySelector(".maplibregl-canvas-container")).toBeDefined();
});
it("opens maximised map view on click when beacon has a live location", () => {
const room = makeRoomWithStateEvents([aliceBeaconInfo], { roomId, mockClient });
const beaconInstance = room.currentState.beacons.get(getBeaconInfoIdentifier(aliceBeaconInfo))!;
beaconInstance.addLocations([location1]);
const component = getComponent({ mxEvent: aliceBeaconInfo });
fireEvent.click(component.container.querySelector(".mx_Map")!);
// opens modal
expect(modalSpy).toHaveBeenCalled();
});
it("updates latest location", () => {
const room = makeRoomWithStateEvents([aliceBeaconInfo], { roomId, mockClient });
getComponent({ mxEvent: aliceBeaconInfo });
const beaconInstance = room.currentState.beacons.get(getBeaconInfoIdentifier(aliceBeaconInfo))!;
act(() => {
beaconInstance.addLocations([location1]);
});
expect(mockMap.setCenter).toHaveBeenCalledWith({ lat: 51, lon: 41 });
expect(mockMarker.setLngLat).toHaveBeenCalledWith({ lat: 51, lon: 41 });
act(() => {
beaconInstance.addLocations([location2]);
});
expect(mockMap.setCenter).toHaveBeenCalledWith({ lat: 52, lon: 42 });
expect(mockMarker.setLngLat).toHaveBeenCalledWith({ lat: 52, lon: 42 });
});
});
describe("redaction", () => {
const makeEvents = (): {
beaconInfoEvent: MatrixEvent;
location1: MatrixEvent;
location2: MatrixEvent;
} => {
const beaconInfoEvent = makeBeaconInfoEvent(aliceId, roomId, { isLive: true }, "$alice-room1-1");
const location1 = makeBeaconEvent(
aliceId,
{ beaconInfoId: beaconInfoEvent.getId(), geoUri: "geo:51,41", timestamp: now + 1 },
roomId,
);
location1.event.event_id = "1";
const location2 = makeBeaconEvent(
aliceId,
{ beaconInfoId: beaconInfoEvent.getId(), geoUri: "geo:52,42", timestamp: now + 10000 },
roomId,
);
location2.event.event_id = "2";
return { beaconInfoEvent, location1, location2 };
};
const redactionEvent = new MatrixEvent({ type: EventType.RoomRedaction, content: { reason: "test reason" } });
const setupRoomWithBeacon = (beaconInfoEvent: MatrixEvent, locationEvents: MatrixEvent[] = []): Room => {
const room = makeRoomWithStateEvents([beaconInfoEvent], { roomId, mockClient });
const beaconInstance = room.currentState.beacons.get(getBeaconInfoIdentifier(beaconInfoEvent))!;
beaconInstance.addLocations(locationEvents);
return room;
};
const mockGetRelationsForEvent = (locationEvents: MatrixEvent[] = []) => {
const relations = new Relations(RelationType.Reference, M_BEACON.name, mockClient);
jest.spyOn(relations, "getRelations").mockReturnValue(locationEvents);
const getRelationsForEvent = jest.fn().mockReturnValue(relations);
return getRelationsForEvent;
};
it("does nothing when getRelationsForEvent is falsy", () => {
const { beaconInfoEvent, location1, location2 } = makeEvents();
const room = setupRoomWithBeacon(beaconInfoEvent, [location1, location2]);
getComponent({ mxEvent: beaconInfoEvent });
act(() => {
beaconInfoEvent.makeRedacted(redactionEvent, room);
});
// no error, no redactions
expect(mockClient.redactEvent).not.toHaveBeenCalled();
});
it("cleans up redaction listener on unmount", () => {
const { beaconInfoEvent, location1, location2 } = makeEvents();
setupRoomWithBeacon(beaconInfoEvent, [location1, location2]);
const removeListenerSpy = jest.spyOn(beaconInfoEvent, "removeListener");
const component = getComponent({ mxEvent: beaconInfoEvent });
act(() => {
component.unmount();
});
expect(removeListenerSpy).toHaveBeenCalled();
});
it("does nothing when beacon has no related locations", async () => {
const { beaconInfoEvent } = makeEvents();
// no locations
const room = setupRoomWithBeacon(beaconInfoEvent, []);
const getRelationsForEvent = await mockGetRelationsForEvent();
getComponent({ mxEvent: beaconInfoEvent, getRelationsForEvent });
act(() => {
beaconInfoEvent.makeRedacted(redactionEvent, room);
});
expect(getRelationsForEvent).toHaveBeenCalledWith(
beaconInfoEvent.getId(),
RelationType.Reference,
M_BEACON.name,
);
expect(mockClient.redactEvent).not.toHaveBeenCalled();
});
it("redacts related locations on beacon redaction", async () => {
const { beaconInfoEvent, location1, location2 } = makeEvents();
const room = setupRoomWithBeacon(beaconInfoEvent, [location1, location2]);
const getRelationsForEvent = await mockGetRelationsForEvent([location1, location2]);
getComponent({ mxEvent: beaconInfoEvent, getRelationsForEvent });
act(() => {
beaconInfoEvent.makeRedacted(redactionEvent, room);
});
expect(getRelationsForEvent).toHaveBeenCalledWith(
beaconInfoEvent.getId(),
RelationType.Reference,
M_BEACON.name,
);
expect(mockClient.redactEvent).toHaveBeenCalledTimes(2);
expect(mockClient.redactEvent).toHaveBeenCalledWith(roomId, location1.getId(), undefined, {
reason: "test reason",
});
expect(mockClient.redactEvent).toHaveBeenCalledWith(roomId, location2.getId(), undefined, {
reason: "test reason",
});
});
});
describe("when map display is not configured", () => {
beforeEach(() => {
// mock map utils to raise MapStyleUrlNotConfigured error
jest.spyOn(mapUtilHooks, "useMap").mockImplementation(({ onError }) => {
onError?.(new Error(LocationShareError.MapStyleUrlNotConfigured));
return mockMap;
});
});
it("renders maps unavailable error for a live beacon with location", () => {
const beaconInfoEvent = makeBeaconInfoEvent(aliceId, roomId, { isLive: true }, "$alice-room1-1");
const location1 = makeBeaconEvent(aliceId, {
beaconInfoId: beaconInfoEvent.getId(),
geoUri: "geo:51,41",
timestamp: now + 1,
});
makeRoomWithBeacons(roomId, mockClient, [beaconInfoEvent], [location1]);
const component = getComponent({ mxEvent: beaconInfoEvent });
expect(component.getByTestId("map-rendering-error")).toMatchSnapshot();
});
// test that statuses display as expected with a map display error
testBeaconStatuses();
});
});

View file

@ -0,0 +1,88 @@
/*
Copyright 2024 New Vector Ltd.
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 { EventType, getHttpUriForMxc, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks";
import {
getMockClientWithEventEmitter,
mockClientMethodsCrypto,
mockClientMethodsDevice,
mockClientMethodsServer,
mockClientMethodsUser,
} from "../../../test-utils";
import { MediaEventHelper } from "../../../../src/utils/MediaEventHelper";
import SettingsStore from "../../../../src/settings/SettingsStore";
import MFileBody from "../../../../src/components/views/messages/MFileBody.tsx";
import RoomContext, { TimelineRenderingType } from "../../../../src/contexts/RoomContext.ts";
jest.mock("matrix-encrypt-attachment", () => ({
decryptAttachment: jest.fn(),
}));
describe("<MFileBody/>", () => {
const userId = "@user:server";
const deviceId = "DEADB33F";
const cli = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
...mockClientMethodsServer(),
...mockClientMethodsDevice(deviceId),
...mockClientMethodsCrypto(),
getRooms: jest.fn().mockReturnValue([]),
getIgnoredUsers: jest.fn(),
getVersions: jest.fn().mockResolvedValue({
unstable_features: {
"org.matrix.msc3882": true,
"org.matrix.msc3886": true,
},
}),
});
// eslint-disable-next-line no-restricted-properties
cli.mxcUrlToHttp.mockImplementation(
(mxcUrl: string, width?: number, height?: number, resizeMethod?: string, allowDirectLinks?: boolean) => {
return getHttpUriForMxc("https://server", mxcUrl, width, height, resizeMethod, allowDirectLinks);
},
);
const mediaEvent = new MatrixEvent({
room_id: "!room:server",
sender: userId,
type: EventType.RoomMessage,
content: {
body: "alt for a image",
msgtype: "m.image",
url: "mxc://server/image",
},
});
const props = {
onHeightChanged: jest.fn(),
onMessageAllowed: jest.fn(),
permalinkCreator: new RoomPermalinkCreator(new Room(mediaEvent.getRoomId()!, cli, cli.getUserId()!)),
};
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockRestore();
});
it("should show a download button in file rendering type", async () => {
const { container, getByRole } = render(
<RoomContext.Provider value={{ timelineRenderingType: TimelineRenderingType.File } as any}>
<MFileBody
{...props}
mxEvent={mediaEvent}
mediaEventHelper={new MediaEventHelper(mediaEvent)}
showGenericPlaceholder={false}
/>
</RoomContext.Provider>,
);
expect(getByRole("link", { name: "Download" })).toBeInTheDocument();
expect(container).toMatchSnapshot();
});
});

View file

@ -0,0 +1,279 @@
/*
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 { fireEvent, render, screen, waitForElementToBeRemoved } from "jest-matrix-react";
import { EventType, getHttpUriForMxc, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import fetchMock from "fetch-mock-jest";
import encrypt from "matrix-encrypt-attachment";
import { mocked } from "jest-mock";
import fs from "fs";
import path from "path";
import userEvent from "@testing-library/user-event";
import MImageBody from "../../../../src/components/views/messages/MImageBody";
import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks";
import {
getMockClientWithEventEmitter,
mockClientMethodsCrypto,
mockClientMethodsDevice,
mockClientMethodsServer,
mockClientMethodsUser,
} from "../../../test-utils";
import { MediaEventHelper } from "../../../../src/utils/MediaEventHelper";
import SettingsStore from "../../../../src/settings/SettingsStore";
jest.mock("matrix-encrypt-attachment", () => ({
decryptAttachment: jest.fn(),
}));
describe("<MImageBody/>", () => {
const userId = "@user:server";
const deviceId = "DEADB33F";
const cli = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
...mockClientMethodsServer(),
...mockClientMethodsDevice(deviceId),
...mockClientMethodsCrypto(),
getRooms: jest.fn().mockReturnValue([]),
getIgnoredUsers: jest.fn(),
getVersions: jest.fn().mockResolvedValue({
unstable_features: {
"org.matrix.msc3882": true,
"org.matrix.msc3886": true,
},
}),
});
const url = "https://server/_matrix/media/v3/download/server/encrypted-image";
// eslint-disable-next-line no-restricted-properties
cli.mxcUrlToHttp.mockImplementation(
(mxcUrl: string, width?: number, height?: number, resizeMethod?: string, allowDirectLinks?: boolean) => {
return getHttpUriForMxc("https://server", mxcUrl, width, height, resizeMethod, allowDirectLinks);
},
);
const encryptedMediaEvent = new MatrixEvent({
room_id: "!room:server",
sender: userId,
type: EventType.RoomMessage,
content: {
body: "alt for a test image",
info: {
w: 40,
h: 50,
},
file: {
url: "mxc://server/encrypted-image",
},
},
});
const props = {
onHeightChanged: jest.fn(),
onMessageAllowed: jest.fn(),
permalinkCreator: new RoomPermalinkCreator(new Room(encryptedMediaEvent.getRoomId()!, cli, cli.getUserId()!)),
};
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockRestore();
fetchMock.mockReset();
});
it("should show a thumbnail while image is being downloaded", async () => {
fetchMock.getOnce(url, { status: 200 });
const { container } = render(
<MImageBody
{...props}
mxEvent={encryptedMediaEvent}
mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)}
/>,
);
// thumbnail with dimensions present
expect(container).toMatchSnapshot();
});
it("should show error when encrypted media cannot be downloaded", async () => {
fetchMock.getOnce(url, { status: 500 });
render(
<MImageBody
{...props}
mxEvent={encryptedMediaEvent}
mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)}
/>,
);
expect(fetchMock).toHaveBeenCalledWith(url);
await screen.findByText("Error downloading image");
});
it("should show error when encrypted media cannot be decrypted", async () => {
fetchMock.getOnce(url, "thisistotallyanencryptedpng");
mocked(encrypt.decryptAttachment).mockRejectedValue(new Error("Failed to decrypt"));
render(
<MImageBody
{...props}
mxEvent={encryptedMediaEvent}
mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)}
/>,
);
await screen.findByText("Error decrypting image");
});
describe("with image previews/thumbnails disabled", () => {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
});
it("should not download image", async () => {
fetchMock.getOnce(url, { status: 200 });
render(
<MImageBody
{...props}
mxEvent={encryptedMediaEvent}
mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)}
/>,
);
expect(fetchMock).not.toHaveFetched(url);
});
it("should render hidden image placeholder", async () => {
fetchMock.getOnce(url, { status: 200 });
render(
<MImageBody
{...props}
mxEvent={encryptedMediaEvent}
mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)}
/>,
);
expect(screen.getByText("Show image")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button"));
// image fetched after clicking show image
expect(fetchMock).toHaveFetched(url);
// spinner while downloading image
expect(screen.getByRole("progressbar")).toBeInTheDocument();
});
});
it("should fall back to /download/ if /thumbnail/ fails", async () => {
const thumbUrl = "https://server/_matrix/media/v3/thumbnail/server/image?width=800&height=600&method=scale";
const downloadUrl = "https://server/_matrix/media/v3/download/server/image";
const event = new MatrixEvent({
room_id: "!room:server",
sender: userId,
type: EventType.RoomMessage,
content: {
body: "alt for a test image",
info: {
w: 40,
h: 50,
},
url: "mxc://server/image",
},
});
const { container } = render(
<MImageBody {...props} mxEvent={event} mediaEventHelper={new MediaEventHelper(event)} />,
);
const img = container.querySelector(".mx_MImageBody_thumbnail")!;
expect(img).toHaveProperty("src", thumbUrl);
fireEvent.error(img);
expect(img).toHaveProperty("src", downloadUrl);
});
it("should generate a thumbnail if one isn't included for animated media", async () => {
Object.defineProperty(global.Image.prototype, "src", {
set(src) {
window.setTimeout(() => this.onload?.());
},
});
Object.defineProperty(global.Image.prototype, "height", {
get() {
return 600;
},
});
Object.defineProperty(global.Image.prototype, "width", {
get() {
return 800;
},
});
mocked(global.URL.createObjectURL).mockReturnValue("blob:generated-thumb");
fetchMock.getOnce(
"https://server/_matrix/media/v3/download/server/image",
{
body: fs.readFileSync(path.resolve(__dirname, "..", "..", "..", "images", "animated-logo.webp")),
},
{ sendAsJson: false },
);
const event = new MatrixEvent({
room_id: "!room:server",
sender: userId,
type: EventType.RoomMessage,
content: {
body: "alt for a test image",
info: {
w: 40,
h: 50,
mimetype: "image/webp",
},
url: "mxc://server/image",
},
});
const { container } = render(
<MImageBody {...props} mxEvent={event} mediaEventHelper={new MediaEventHelper(event)} />,
);
// Wait for spinners to go away
await waitForElementToBeRemoved(screen.getAllByRole("progressbar"));
// thumbnail with dimensions present
expect(container).toMatchSnapshot();
});
it("should show banner on hover", async () => {
const event = new MatrixEvent({
room_id: "!room:server",
sender: userId,
type: EventType.RoomMessage,
content: {
body: "alt for a test image",
info: {
w: 40,
h: 50,
},
url: "mxc://server/image",
},
});
const { container } = render(
<MImageBody {...props} mxEvent={event} mediaEventHelper={new MediaEventHelper(event)} />,
);
const img = container.querySelector(".mx_MImageBody_thumbnail")!;
await userEvent.hover(img);
expect(container.querySelector(".mx_MImageBody_banner")).toHaveTextContent("...alt for a test image");
});
});

View file

@ -0,0 +1,91 @@
/*
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 { RenderResult, render } from "jest-matrix-react";
import { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix";
import MKeyVerificationRequest from "../../../../src/components/views/messages/MKeyVerificationRequest";
import TileErrorBoundary from "../../../../src/components/views/messages/TileErrorBoundary";
import { Layout } from "../../../../src/settings/enums/Layout";
import MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
import { filterConsole } from "../../../test-utils";
describe("MKeyVerificationRequest", () => {
filterConsole(
"The above error occurred in the <MKeyVerificationRequest> component",
"Error: Attempting to render verification request without a client context!",
"Error: Verification request did not include a sender!",
"Error: Verification request did not include a room ID!",
);
it("shows an error if not wrapped in a client context", () => {
const event = new MatrixEvent({ type: "m.key.verification.request" });
const { container } = renderEventNoClient(event);
expect(container).toHaveTextContent("Can't load this message");
});
it("shows an error if the event has no sender", () => {
const { client } = setup();
const event = new MatrixEvent({ type: "m.key.verification.request" });
const { container } = renderEvent(client, event);
expect(container).toHaveTextContent("Can't load this message");
});
it("shows an error if the event has no room", () => {
const { client } = setup();
const event = new MatrixEvent({ type: "m.key.verification.request", sender: "@a:b.co" });
const { container } = renderEvent(client, event);
expect(container).toHaveTextContent("Can't load this message");
});
it("displays a request from me", () => {
const { client, myUserId } = setup();
const event = new MatrixEvent({ type: "m.key.verification.request", sender: myUserId, room_id: "!x:y.co" });
const { container } = renderEvent(client, event);
expect(container).toHaveTextContent("You sent a verification request");
});
it("displays a request from someone else to me", () => {
const otherUserId = "@other:s.uk";
const { client } = setup();
const event = new MatrixEvent({ type: "m.key.verification.request", sender: otherUserId, room_id: "!x:y.co" });
const { container } = renderEvent(client, event);
expect(container).toHaveTextContent("other:s.uk wants to verify");
});
});
function renderEventNoClient(event: MatrixEvent): RenderResult {
return render(
<TileErrorBoundary mxEvent={event} layout={Layout.Group}>
<MKeyVerificationRequest mxEvent={event} />
</TileErrorBoundary>,
);
}
function renderEvent(client: MatrixClient, event: MatrixEvent): RenderResult {
return render(
<TileErrorBoundary mxEvent={event} layout={Layout.Group}>
<MatrixClientContext.Provider value={client}>
<MKeyVerificationRequest mxEvent={event} />
</MatrixClientContext.Provider>
,
</TileErrorBoundary>,
);
}
function setup(): { client: MatrixClient; myUserId: string } {
const myUserId = "@me:s.co";
const client = {
getSafeUserId: jest.fn().mockReturnValue(myUserId),
getRoom: jest.fn(),
} as unknown as MatrixClient;
return { client, myUserId };
}

View file

@ -0,0 +1,162 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 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 { LocationAssetType, ClientEvent, RoomMember, SyncState } from "matrix-js-sdk/src/matrix";
import * as maplibregl from "maplibre-gl";
import { logger } from "matrix-js-sdk/src/logger";
import { sleep } from "matrix-js-sdk/src/utils";
import MLocationBody from "../../../../src/components/views/messages/MLocationBody";
import MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks";
import { MediaEventHelper } from "../../../../src/utils/MediaEventHelper";
import Modal from "../../../../src/Modal";
import SdkConfig from "../../../../src/SdkConfig";
import { TILE_SERVER_WK_KEY } from "../../../../src/utils/WellKnownUtils";
import { makeLocationEvent } from "../../../test-utils/location";
import { getMockClientWithEventEmitter } from "../../../test-utils";
describe("MLocationBody", () => {
const mapOptions = { container: {} as unknown as HTMLElement, style: "" };
describe("<MLocationBody>", () => {
const roomId = "!room:server";
const userId = "@user:server";
const mockClient = getMockClientWithEventEmitter({
getClientWellKnown: jest.fn().mockReturnValue({
[TILE_SERVER_WK_KEY.name]: { map_style_url: "maps.com" },
}),
isGuest: jest.fn().mockReturnValue(false),
});
const defaultEvent = makeLocationEvent("geo:51.5076,-0.1276", LocationAssetType.Pin);
const defaultProps: MLocationBody["props"] = {
mxEvent: defaultEvent,
highlights: [],
highlightLink: "",
onHeightChanged: jest.fn(),
onMessageAllowed: jest.fn(),
permalinkCreator: {} as RoomPermalinkCreator,
mediaEventHelper: {} as MediaEventHelper,
};
const getComponent = (props = {}) =>
render(
<MatrixClientContext.Provider value={mockClient}>
<MLocationBody {...defaultProps} {...props} />
</MatrixClientContext.Provider>,
);
const getMapErrorComponent = () => {
const mockMap = new maplibregl.Map(mapOptions);
mockClient.getClientWellKnown.mockReturnValue({
[TILE_SERVER_WK_KEY.name]: { map_style_url: "bad-tile-server.com" },
});
const component = getComponent();
sleep(10).then(() => {
// simulate error initialising map in maplibregl
// @ts-ignore
mockMap.emit("error", { status: 404 });
});
return component;
};
beforeEach(() => {
jest.clearAllMocks();
});
describe("with error", () => {
let sdkConfigSpy: jest.SpyInstance<any>;
beforeEach(() => {
// eat expected errors to keep console clean
jest.spyOn(logger, "error").mockImplementation(() => {});
mockClient.getClientWellKnown.mockReturnValue({});
sdkConfigSpy = jest.spyOn(SdkConfig, "get").mockReturnValue({});
});
afterAll(() => {
sdkConfigSpy.mockRestore();
jest.spyOn(logger, "error").mockRestore();
});
it("displays correct fallback content without error style when map_style_url is not configured", async () => {
const component = getComponent();
// The map code needs to be lazy loaded so this will take some time to appear
await waitFor(() =>
expect(component.container.querySelector(".mx_EventTile_body")).toBeInTheDocument(),
);
expect(component.container.querySelector(".mx_EventTile_body")).toMatchSnapshot();
});
it("displays correct fallback content when map_style_url is misconfigured", async () => {
const component = getMapErrorComponent();
await waitFor(() => expect(component.container.querySelector(".mx_EventTile_body")).toBeTruthy());
await waitFor(() => expect(component.container.querySelector(".mx_EventTile_body")).toMatchSnapshot());
});
it("should clear the error on reconnect", () => {
const component = getMapErrorComponent();
expect(component.container.querySelector(".mx_EventTile_tileError")).toBeDefined();
mockClient.emit(ClientEvent.Sync, SyncState.Reconnecting, SyncState.Error);
expect(component.container.querySelector(".mx_EventTile_tileError")).toBeFalsy();
});
});
describe("without error", () => {
beforeEach(() => {
mockClient.getClientWellKnown.mockReturnValue({
[TILE_SERVER_WK_KEY.name]: { map_style_url: "maps.com" },
});
// MLocationBody uses random number for map id
// stabilise for test
jest.spyOn(global.Math, "random").mockReturnValue(0.123456);
});
afterAll(() => {
jest.spyOn(global.Math, "random").mockRestore();
});
it("renders map correctly", () => {
const mockMap = new maplibregl.Map(mapOptions);
const component = getComponent();
expect(component.asFragment()).toMatchSnapshot();
// map was centered
expect(mockMap.setCenter).toHaveBeenCalledWith({
lat: 51.5076,
lon: -0.1276,
});
});
it("opens map dialog on click", async () => {
const modalSpy = jest
.spyOn(Modal, "createDialog")
.mockReturnValue({ finished: new Promise(() => {}), close: jest.fn() });
const component = getComponent();
await fireEvent.click(component.container.querySelector(".mx_Map")!);
expect(modalSpy).toHaveBeenCalled();
});
it("renders marker correctly for a self share", () => {
const selfShareEvent = makeLocationEvent("geo:51.5076,-0.1276", LocationAssetType.Self);
const member = new RoomMember(roomId, userId);
// @ts-ignore cheat assignment to property
selfShareEvent.sender = member;
const component = getComponent({ mxEvent: selfShareEvent });
// render self locations with user avatars
expect(component.asFragment()).toMatchSnapshot();
});
});
});
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,193 @@
/*
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, waitFor } from "jest-matrix-react";
import { EventTimeline, MatrixEvent, Room, M_TEXT } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { IBodyProps } from "../../../../src/components/views/messages/IBodyProps";
import { MPollEndBody } from "../../../../src/components/views/messages/MPollEndBody";
import MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks";
import { MediaEventHelper } from "../../../../src/utils/MediaEventHelper";
import {
flushPromises,
getMockClientWithEventEmitter,
makePollEndEvent,
makePollStartEvent,
mockClientMethodsEvents,
mockClientMethodsUser,
setupRoomWithPollEvents,
} from "../../../test-utils";
describe("<MPollEndBody />", () => {
const userId = "@alice:domain.org";
const roomId = "!room:domain.org";
const mockClient = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
...mockClientMethodsEvents(),
getRoom: jest.fn(),
relations: jest.fn(),
fetchRoomEvent: jest.fn(),
});
const pollStartEvent = makePollStartEvent("Question?", userId, undefined, { roomId });
const pollEndEvent = makePollEndEvent(pollStartEvent.getId()!, roomId, userId, 123);
const setupRoomWithEventsTimeline = async (pollEnd: MatrixEvent, pollStart?: MatrixEvent): Promise<Room> => {
if (pollStart) {
await setupRoomWithPollEvents([pollStart], [], [pollEnd], mockClient);
}
const room = mockClient.getRoom(roomId) || new Room(roomId, mockClient, userId);
// end events validate against this
jest.spyOn(room.currentState, "maySendRedactionForEvent").mockImplementation(
(_evt: MatrixEvent, id: string) => {
return id === mockClient.getSafeUserId();
},
);
const timelineSet = room.getUnfilteredTimelineSet();
const getTimelineForEventSpy = jest.spyOn(timelineSet, "getTimelineForEvent");
// if we have a pollStart, mock the room timeline to include it
if (pollStart) {
const eventTimeline = {
getEvents: jest.fn().mockReturnValue([pollEnd, pollStart]),
} as unknown as EventTimeline;
getTimelineForEventSpy.mockReturnValue(eventTimeline);
}
mockClient.getRoom.mockReturnValue(room);
return room;
};
const defaultProps = {
mxEvent: pollEndEvent,
highlightLink: "unused",
mediaEventHelper: {} as unknown as MediaEventHelper,
onHeightChanged: () => {},
onMessageAllowed: () => {},
permalinkCreator: {} as unknown as RoomPermalinkCreator,
ref: undefined as any,
};
const getComponent = (props: Partial<IBodyProps> = {}) =>
render(<MPollEndBody {...defaultProps} {...props} />, {
wrapper: ({ children }) => (
<MatrixClientContext.Provider value={mockClient}>{children}</MatrixClientContext.Provider>
),
});
beforeEach(() => {
mockClient.getRoom.mockReset();
mockClient.relations.mockResolvedValue({
events: [],
});
mockClient.fetchRoomEvent.mockResolvedValue(pollStartEvent.getEffectiveEvent());
});
afterEach(() => {
jest.spyOn(logger, "error").mockRestore();
});
describe("when poll start event exists in current timeline", () => {
it("renders an ended poll", async () => {
await setupRoomWithEventsTimeline(pollEndEvent, pollStartEvent);
const { container } = getComponent();
// ended poll rendered
expect(container).toMatchSnapshot();
// didnt try to fetch start event while it was already in timeline
expect(mockClient.fetchRoomEvent).not.toHaveBeenCalled();
});
it("does not render a poll tile when end event is invalid", async () => {
// sender of end event does not match start event
const invalidEndEvent = makePollEndEvent(pollStartEvent.getId()!, roomId, "@mallory:domain.org", 123);
await setupRoomWithEventsTimeline(invalidEndEvent, pollStartEvent);
const { getByText } = getComponent({ mxEvent: invalidEndEvent });
// no poll tile rendered
expect(getByText("The poll has ended. Something.")).toBeTruthy();
});
});
describe("when poll start event does not exist in current timeline", () => {
it("fetches the related poll start event and displays a poll tile", async () => {
await setupRoomWithEventsTimeline(pollEndEvent);
const { container, getByTestId, getByRole } = getComponent();
// while fetching event, only icon is shown
expect(container).toMatchSnapshot();
await waitFor(() => expect(getByRole("progressbar")).toBeInTheDocument());
expect(mockClient.fetchRoomEvent).toHaveBeenCalledWith(roomId, pollStartEvent.getId());
// quick check for poll tile
expect(getByTestId("pollQuestion").innerHTML).toEqual("Question?");
expect(getByTestId("totalVotes").innerHTML).toEqual("Final result based on 0 votes");
});
it("does not render a poll tile when end event is invalid", async () => {
// sender of end event does not match start event
const invalidEndEvent = makePollEndEvent(pollStartEvent.getId()!, roomId, "@mallory:domain.org", 123);
await setupRoomWithEventsTimeline(invalidEndEvent);
const { getByText } = getComponent({ mxEvent: invalidEndEvent });
// flush the fetch event promise
await flushPromises();
// no poll tile rendered
expect(getByText("The poll has ended. Something.")).toBeTruthy();
});
it("logs an error and displays the text fallback when fetching the start event fails", async () => {
await setupRoomWithEventsTimeline(pollEndEvent);
mockClient.fetchRoomEvent.mockRejectedValue({ code: 404 });
const logSpy = jest.spyOn(logger, "error").mockImplementation(() => {});
const { getByText } = getComponent();
// flush the fetch event promise
await flushPromises();
// poll end event fallback text used
expect(getByText("The poll has ended. Something.")).toBeTruthy();
expect(logSpy).toHaveBeenCalledWith("Failed to fetch related poll start event", { code: 404 });
});
it("logs an error and displays the extensible event text when fetching the start event fails", async () => {
await setupRoomWithEventsTimeline(pollEndEvent);
mockClient.fetchRoomEvent.mockRejectedValue({ code: 404 });
const logSpy = jest.spyOn(logger, "error").mockImplementation(() => {});
const { getByText } = getComponent();
// flush the fetch event promise
await flushPromises();
// poll end event fallback text used
expect(getByText("The poll has ended. Something.")).toBeTruthy();
expect(logSpy).toHaveBeenCalledWith("Failed to fetch related poll start event", { code: 404 });
});
it("displays fallback text when the poll end event does not have text", async () => {
const endWithoutText = makePollEndEvent(pollStartEvent.getId()!, roomId, userId, 123);
delete endWithoutText.getContent()[M_TEXT.name];
await setupRoomWithEventsTimeline(endWithoutText);
mockClient.fetchRoomEvent.mockRejectedValue({ code: 404 });
const { getByText } = getComponent({ mxEvent: endWithoutText });
// flush the fetch event promise
await flushPromises();
// default fallback text used
expect(getByText("@alice:domain.org has ended a poll")).toBeTruthy();
});
});
});

View file

@ -0,0 +1,86 @@
/*
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, getHttpUriForMxc, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import fetchMock from "fetch-mock-jest";
import userEvent from "@testing-library/user-event";
import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks";
import {
getMockClientWithEventEmitter,
mockClientMethodsCrypto,
mockClientMethodsDevice,
mockClientMethodsServer,
mockClientMethodsUser,
} from "../../../test-utils";
import SettingsStore from "../../../../src/settings/SettingsStore";
import MStickerBody from "../../../../src/components/views/messages/MStickerBody";
describe("<MStickerBody/>", () => {
const userId = "@user:server";
const deviceId = "DEADB33F";
const cli = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
...mockClientMethodsServer(),
...mockClientMethodsDevice(deviceId),
...mockClientMethodsCrypto(),
getRooms: jest.fn().mockReturnValue([]),
getIgnoredUsers: jest.fn(),
getVersions: jest.fn().mockResolvedValue({
unstable_features: {
"org.matrix.msc3882": true,
"org.matrix.msc3886": true,
},
}),
});
const url = "https://server/_matrix/media/v3/download/server/sticker";
// eslint-disable-next-line no-restricted-properties
cli.mxcUrlToHttp.mockImplementation(
(mxcUrl: string, width?: number, height?: number, resizeMethod?: string, allowDirectLinks?: boolean) => {
return getHttpUriForMxc("https://server", mxcUrl, width, height, resizeMethod, allowDirectLinks);
},
);
const mediaEvent = new MatrixEvent({
room_id: "!room:server",
sender: userId,
type: EventType.RoomMessage,
content: {
body: "sticker description",
info: {
w: 40,
h: 50,
},
file: {
url: "mxc://server/sticker",
},
},
});
const props = {
onHeightChanged: jest.fn(),
onMessageAllowed: jest.fn(),
permalinkCreator: new RoomPermalinkCreator(new Room(mediaEvent.getRoomId()!, cli, cli.getUserId()!)),
};
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockRestore();
fetchMock.mockReset();
});
it("should show a tooltip on hover", async () => {
fetchMock.getOnce(url, { status: 200 });
render(<MStickerBody {...props} mxEvent={mediaEvent} />);
expect(screen.queryByRole("tooltip")).toBeNull();
await userEvent.hover(screen.getByRole("img"));
await expect(screen.findByRole("tooltip")).resolves.toHaveTextContent("sticker description");
});
});

View file

@ -0,0 +1,131 @@
/*
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 { EventType, getHttpUriForMxc, IContent, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { render, RenderResult } from "jest-matrix-react";
import fetchMock from "fetch-mock-jest";
import MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks";
import { MediaEventHelper } from "../../../../src/utils/MediaEventHelper";
import {
getMockClientWithEventEmitter,
mockClientMethodsCrypto,
mockClientMethodsDevice,
mockClientMethodsServer,
mockClientMethodsUser,
} from "../../../test-utils";
import MVideoBody from "../../../../src/components/views/messages/MVideoBody";
describe("MVideoBody", () => {
it("does not crash when given a portrait image", () => {
// Check for an unreliable crash caused by a fractional-sized
// image dimension being used for a CanvasImageData.
const { asFragment } = makeMVideoBody(720, 1280);
expect(asFragment()).toMatchSnapshot();
// If we get here, we did not crash.
});
it("should show poster for encrypted media before downloading it", async () => {
const userId = "@user:server";
const deviceId = "DEADB33F";
const cli = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
...mockClientMethodsServer(),
...mockClientMethodsDevice(deviceId),
...mockClientMethodsCrypto(),
getRooms: jest.fn().mockReturnValue([]),
getIgnoredUsers: jest.fn(),
getVersions: jest.fn().mockResolvedValue({
unstable_features: {
"org.matrix.msc3882": true,
"org.matrix.msc3886": true,
},
}),
});
const thumbUrl = "https://server/_matrix/media/v3/download/server/encrypted-poster";
fetchMock.getOnce(thumbUrl, { status: 200 });
// eslint-disable-next-line no-restricted-properties
cli.mxcUrlToHttp.mockImplementation(
(mxcUrl: string, width?: number, height?: number, resizeMethod?: string, allowDirectLinks?: boolean) => {
return getHttpUriForMxc("https://server", mxcUrl, width, height, resizeMethod, allowDirectLinks);
},
);
const encryptedMediaEvent = new MatrixEvent({
room_id: "!room:server",
sender: userId,
type: EventType.RoomMessage,
content: {
body: "alt for a test video",
info: {
duration: 420,
w: 40,
h: 50,
thumbnail_file: {
url: "mxc://server/encrypted-poster",
},
},
file: {
url: "mxc://server/encrypted-image",
},
},
});
const { asFragment } = render(
<MVideoBody mxEvent={encryptedMediaEvent} mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)} />,
);
expect(asFragment()).toMatchSnapshot();
});
});
function makeMVideoBody(w: number, h: number): RenderResult {
const content: IContent = {
info: {
"w": w,
"h": h,
"mimetype": "video/mp4",
"size": 2495675,
"thumbnail_file": {
url: "",
key: { alg: "", key_ops: [], kty: "", k: "", ext: true },
iv: "",
hashes: {},
v: "",
},
"thumbnail_info": { mimetype: "" },
"xyz.amorgan.blurhash": "TrGl6bofof~paxWC?bj[oL%2fPj]",
},
url: "http://example.com",
};
const event = new MatrixEvent({
content,
});
const defaultProps: MVideoBody["props"] = {
mxEvent: event,
highlights: [],
highlightLink: "",
onHeightChanged: jest.fn(),
onMessageAllowed: jest.fn(),
permalinkCreator: {} as RoomPermalinkCreator,
mediaEventHelper: { media: { isEncrypted: false } } as MediaEventHelper,
};
const mockClient = getMockClientWithEventEmitter({
mxcUrlToHttp: jest.fn(),
});
return render(
<MatrixClientContext.Provider value={mockClient}>
<MVideoBody {...defaultProps} />
</MatrixClientContext.Provider>,
);
}

View file

@ -0,0 +1,519 @@
/*
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 { act, render, fireEvent, screen, waitFor } from "jest-matrix-react";
import {
EventType,
EventStatus,
MatrixEvent,
MatrixEventEvent,
MsgType,
Room,
FeatureSupport,
Thread,
EventTimeline,
RoomStateEvent,
} from "matrix-js-sdk/src/matrix";
import MessageActionBar from "../../../../src/components/views/messages/MessageActionBar";
import {
getMockClientWithEventEmitter,
mockClientMethodsUser,
mockClientMethodsEvents,
makeBeaconInfoEvent,
} from "../../../test-utils";
import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks";
import RoomContext, { TimelineRenderingType } from "../../../../src/contexts/RoomContext";
import { IRoomState } from "../../../../src/components/structures/RoomView";
import dispatcher from "../../../../src/dispatcher/dispatcher";
import SettingsStore from "../../../../src/settings/SettingsStore";
import { Action } from "../../../../src/dispatcher/actions";
import PinningUtils from "../../../../src/utils/PinningUtils";
jest.mock("../../../../src/dispatcher/dispatcher");
describe("<MessageActionBar />", () => {
const userId = "@alice:server.org";
const roomId = "!room:server.org";
const client = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
...mockClientMethodsEvents(),
getRoom: jest.fn(),
setRoomAccountData: jest.fn(),
sendStateEvent: jest.fn(),
});
const room = new Room(roomId, client, userId);
const alicesMessageEvent = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
room_id: roomId,
content: {
msgtype: MsgType.Text,
body: "Hello",
},
event_id: "$alices_message",
});
const bobsMessageEvent = new MatrixEvent({
type: EventType.RoomMessage,
sender: "@bob:server.org",
room_id: roomId,
content: {
msgtype: MsgType.Text,
body: "I am bob",
},
event_id: "$bobs_message",
});
const redactedEvent = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
});
redactedEvent.makeRedacted(redactedEvent, room);
const localStorageMock = (() => {
let store: Record<string, any> = {};
return {
getItem: jest.fn().mockImplementation((key) => store[key] ?? null),
setItem: jest.fn().mockImplementation((key, value) => {
store[key] = value;
}),
clear: jest.fn().mockImplementation(() => {
store = {};
}),
removeItem: jest.fn().mockImplementation((key) => delete store[key]),
};
})();
Object.defineProperty(window, "localStorage", {
value: localStorageMock,
writable: true,
});
jest.spyOn(room, "getPendingEvents").mockReturnValue([]);
client.getRoom.mockReturnValue(room);
const defaultProps = {
getTile: jest.fn(),
getReplyChain: jest.fn(),
toggleThreadExpanded: jest.fn(),
mxEvent: alicesMessageEvent,
permalinkCreator: new RoomPermalinkCreator(room),
};
const defaultRoomContext = {
...RoomContext,
timelineRenderingType: TimelineRenderingType.Room,
canSendMessages: true,
canReact: true,
room,
} as unknown as IRoomState;
const getComponent = (props = {}, roomContext: Partial<IRoomState> = {}) =>
render(
<RoomContext.Provider value={{ ...defaultRoomContext, ...roomContext }}>
<MessageActionBar {...defaultProps} {...props} />
</RoomContext.Provider>,
);
beforeEach(() => {
jest.clearAllMocks();
alicesMessageEvent.setStatus(EventStatus.SENT);
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
});
afterAll(() => {
jest.spyOn(SettingsStore, "getValue").mockRestore();
jest.spyOn(SettingsStore, "setValue").mockRestore();
});
it("kills event listeners on unmount", () => {
const offSpy = jest.spyOn(alicesMessageEvent, "off").mockClear();
const wrapper = getComponent({ mxEvent: alicesMessageEvent });
act(() => {
wrapper.unmount();
});
expect(offSpy.mock.calls[0][0]).toEqual(MatrixEventEvent.Status);
expect(offSpy.mock.calls[1][0]).toEqual(MatrixEventEvent.Decrypted);
expect(offSpy.mock.calls[2][0]).toEqual(MatrixEventEvent.BeforeRedaction);
expect(client.decryptEventIfNeeded).toHaveBeenCalled();
});
describe("decryption", () => {
it("decrypts event if needed", () => {
getComponent({ mxEvent: alicesMessageEvent });
expect(client.decryptEventIfNeeded).toHaveBeenCalled();
});
it("updates component on decrypted event", () => {
const decryptingEvent = new MatrixEvent({
type: EventType.RoomMessageEncrypted,
sender: userId,
room_id: roomId,
content: {},
});
jest.spyOn(decryptingEvent, "isBeingDecrypted").mockReturnValue(true);
const { queryByLabelText } = getComponent({ mxEvent: decryptingEvent });
// still encrypted event is not actionable => no reply button
expect(queryByLabelText("Reply")).toBeFalsy();
act(() => {
// ''decrypt'' the event
decryptingEvent.event.type = alicesMessageEvent.getType();
decryptingEvent.event.content = alicesMessageEvent.getContent();
decryptingEvent.emit(MatrixEventEvent.Decrypted, decryptingEvent);
});
// new available actions after decryption
expect(queryByLabelText("Reply")).toBeTruthy();
});
});
describe("status", () => {
it("updates component when event status changes", () => {
alicesMessageEvent.setStatus(EventStatus.QUEUED);
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
// pending event status, cancel action available
expect(queryByLabelText("Delete")).toBeTruthy();
act(() => {
alicesMessageEvent.setStatus(EventStatus.SENT);
});
// event is sent, no longer cancelable
expect(queryByLabelText("Delete")).toBeFalsy();
});
});
describe("redaction", () => {
// this doesn't do what it's supposed to
// because beforeRedaction event is fired... before redaction
// event is unchanged at point when this component updates
// TODO file bug
it.skip("updates component on before redaction event", () => {
const event = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
room_id: roomId,
content: {
msgtype: MsgType.Text,
body: "Hello",
},
});
const { queryByLabelText } = getComponent({ mxEvent: event });
// no pending redaction => no delete button
expect(queryByLabelText("Delete")).toBeFalsy();
act(() => {
const redactionEvent = new MatrixEvent({
type: EventType.RoomRedaction,
sender: userId,
room_id: roomId,
});
redactionEvent.setStatus(EventStatus.QUEUED);
event.markLocallyRedacted(redactionEvent);
});
// updated with local redaction event, delete now available
expect(queryByLabelText("Delete")).toBeTruthy();
});
});
describe("options button", () => {
it("renders options menu", () => {
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
expect(queryByLabelText("Options")).toBeTruthy();
});
it("opens message context menu on click", () => {
const { getByTestId, queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
fireEvent.click(queryByLabelText("Options")!);
expect(getByTestId("mx_MessageContextMenu")).toBeTruthy();
});
});
describe("reply button", () => {
it("renders reply button on own actionable event", () => {
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
expect(queryByLabelText("Reply")).toBeTruthy();
});
it("renders reply button on others actionable event", () => {
const { queryByLabelText } = getComponent({ mxEvent: bobsMessageEvent }, { canSendMessages: true });
expect(queryByLabelText("Reply")).toBeTruthy();
});
it("does not render reply button on non-actionable event", () => {
// redacted event is not actionable
const { queryByLabelText } = getComponent({ mxEvent: redactedEvent });
expect(queryByLabelText("Reply")).toBeFalsy();
});
it("does not render reply button when user cannot send messaged", () => {
// redacted event is not actionable
const { queryByLabelText } = getComponent({ mxEvent: redactedEvent }, { canSendMessages: false });
expect(queryByLabelText("Reply")).toBeFalsy();
});
it("dispatches reply event on click", () => {
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
fireEvent.click(queryByLabelText("Reply")!);
expect(dispatcher.dispatch).toHaveBeenCalledWith({
action: "reply_to_event",
event: alicesMessageEvent,
context: TimelineRenderingType.Room,
});
});
});
describe("react button", () => {
it("renders react button on own actionable event", () => {
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
expect(queryByLabelText("React")).toBeTruthy();
});
it("renders react button on others actionable event", () => {
const { queryByLabelText } = getComponent({ mxEvent: bobsMessageEvent });
expect(queryByLabelText("React")).toBeTruthy();
});
it("does not render react button on non-actionable event", () => {
// redacted event is not actionable
const { queryByLabelText } = getComponent({ mxEvent: redactedEvent });
expect(queryByLabelText("React")).toBeFalsy();
});
it("does not render react button when user cannot react", () => {
// redacted event is not actionable
const { queryByLabelText } = getComponent({ mxEvent: redactedEvent }, { canReact: false });
expect(queryByLabelText("React")).toBeFalsy();
});
it("opens reaction picker on click", () => {
const { queryByLabelText, getByTestId } = getComponent({ mxEvent: alicesMessageEvent });
fireEvent.click(queryByLabelText("React")!);
expect(getByTestId("mx_EmojiPicker")).toBeTruthy();
});
});
describe("cancel button", () => {
it("renders cancel button for an event with a cancelable status", () => {
alicesMessageEvent.setStatus(EventStatus.QUEUED);
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
expect(queryByLabelText("Delete")).toBeTruthy();
});
it("renders cancel button for an event with a pending edit", () => {
const event = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
room_id: roomId,
content: {
msgtype: MsgType.Text,
body: "Hello",
},
});
event.setStatus(EventStatus.SENT);
const replacingEvent = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
room_id: roomId,
content: {
msgtype: MsgType.Text,
body: "replacing event body",
},
});
replacingEvent.setStatus(EventStatus.QUEUED);
event.makeReplaced(replacingEvent);
const { queryByLabelText } = getComponent({ mxEvent: event });
expect(queryByLabelText("Delete")).toBeTruthy();
});
it("renders cancel button for an event with a pending redaction", () => {
const event = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
room_id: roomId,
content: {
msgtype: MsgType.Text,
body: "Hello",
},
});
event.setStatus(EventStatus.SENT);
const redactionEvent = new MatrixEvent({
type: EventType.RoomRedaction,
sender: userId,
room_id: roomId,
});
redactionEvent.setStatus(EventStatus.QUEUED);
event.markLocallyRedacted(redactionEvent);
const { queryByLabelText } = getComponent({ mxEvent: event });
expect(queryByLabelText("Delete")).toBeTruthy();
});
it("renders cancel and retry button for an event with NOT_SENT status", () => {
alicesMessageEvent.setStatus(EventStatus.NOT_SENT);
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
expect(queryByLabelText("Retry")).toBeTruthy();
expect(queryByLabelText("Delete")).toBeTruthy();
});
it.todo("unsends event on cancel click");
it.todo("retrys event on retry click");
});
describe("thread button", () => {
beforeEach(() => {
Thread.setServerSideSupport(FeatureSupport.Stable);
});
describe("when threads feature is enabled", () => {
it("renders thread button on own actionable event", () => {
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
expect(queryByLabelText("Reply in thread")).toBeTruthy();
});
it("does not render thread button for a beacon_info event", () => {
const beaconInfoEvent = makeBeaconInfoEvent(userId, roomId);
const { queryByLabelText } = getComponent({ mxEvent: beaconInfoEvent });
expect(queryByLabelText("Reply in thread")).toBeFalsy();
});
it("opens thread on click", () => {
const { getByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
fireEvent.click(getByLabelText("Reply in thread"));
expect(dispatcher.dispatch).toHaveBeenCalledWith({
action: Action.ShowThread,
rootEvent: alicesMessageEvent,
push: false,
});
});
it("opens parent thread for a thread reply message", () => {
const threadReplyEvent = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
room_id: roomId,
content: {
msgtype: MsgType.Text,
body: "this is a thread reply",
},
});
// mock the thread stuff
jest.spyOn(threadReplyEvent, "isThreadRoot", "get").mockReturnValue(false);
// set alicesMessageEvent as the root event
jest.spyOn(threadReplyEvent, "getThread").mockReturnValue({
rootEvent: alicesMessageEvent,
} as unknown as Thread);
const { getByLabelText } = getComponent({ mxEvent: threadReplyEvent });
fireEvent.click(getByLabelText("Reply in thread"));
expect(dispatcher.dispatch).toHaveBeenCalledWith({
action: Action.ShowThread,
rootEvent: alicesMessageEvent,
initialEvent: threadReplyEvent,
highlighted: true,
scroll_into_view: true,
push: false,
});
});
});
});
it.each([["React"], ["Reply"], ["Reply in thread"], ["Edit"], ["Pin"]])(
"does not show context menu when right-clicking",
(buttonLabel: string) => {
// For favourite and pin buttons
jest.spyOn(SettingsStore, "getValue").mockReturnValue(true);
const event = new MouseEvent("contextmenu", {
bubbles: true,
cancelable: true,
});
event.stopPropagation = jest.fn();
event.preventDefault = jest.fn();
const { queryByTestId, queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
fireEvent(queryByLabelText(buttonLabel)!, event);
expect(event.stopPropagation).toHaveBeenCalled();
expect(event.preventDefault).toHaveBeenCalled();
expect(queryByTestId("mx_MessageContextMenu")).toBeFalsy();
},
);
it("does shows context menu when right-clicking options", () => {
const { queryByTestId, queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
fireEvent.contextMenu(queryByLabelText("Options")!);
expect(queryByTestId("mx_MessageContextMenu")).toBeTruthy();
});
describe("pin button", () => {
beforeEach(() => {
// enable pin button
jest.spyOn(SettingsStore, "getValue").mockReturnValue(true);
jest.spyOn(PinningUtils, "isPinned").mockReturnValue(false);
});
afterEach(() => {
jest.spyOn(
room.getLiveTimeline().getState(EventTimeline.FORWARDS)!,
"mayClientSendStateEvent",
).mockRestore();
});
it("should not render pin button when user can't send state event", () => {
jest.spyOn(
room.getLiveTimeline().getState(EventTimeline.FORWARDS)!,
"mayClientSendStateEvent",
).mockReturnValue(false);
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
expect(queryByLabelText("Pin")).toBeFalsy();
});
it("should render pin button", () => {
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
expect(queryByLabelText("Pin")).toBeTruthy();
});
it("should listen to room pinned events", async () => {
getComponent({ mxEvent: alicesMessageEvent });
expect(screen.getByLabelText("Pin")).toBeInTheDocument();
// Event is considered pinned
jest.spyOn(PinningUtils, "isPinned").mockReturnValue(true);
// Emit that the room pinned events have changed
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
roomState.emit(
RoomStateEvent.Events,
{
getType: () => EventType.RoomPinnedEvents,
} as MatrixEvent,
roomState,
null,
);
await waitFor(() => expect(screen.getByLabelText("Unpin")).toBeInTheDocument());
});
});
});

View file

@ -0,0 +1,132 @@
/*
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, RenderResult } from "jest-matrix-react";
import { MatrixClient, MatrixEvent, EventType, Room, MsgType } from "matrix-js-sdk/src/matrix";
import fetchMock from "fetch-mock-jest";
import fs from "fs";
import path from "path";
import SettingsStore from "../../../../src/settings/SettingsStore";
import { VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from "../../../../src/voice-broadcast";
import { mkEvent, mkRoom, stubClient } from "../../../test-utils";
import MessageEvent from "../../../../src/components/views/messages/MessageEvent";
import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks";
jest.mock("../../../../src/components/views/messages/UnknownBody", () => ({
__esModule: true,
default: () => <div data-testid="unknown-body" />,
}));
jest.mock("../../../../src/voice-broadcast/components/VoiceBroadcastBody", () => ({
VoiceBroadcastBody: () => <div data-testid="voice-broadcast-body" />,
}));
jest.mock("../../../../src/components/views/messages/MImageBody", () => ({
__esModule: true,
default: () => <div data-testid="image-body" />,
}));
jest.mock("../../../../src/components/views/messages/MImageReplyBody", () => ({
__esModule: true,
default: () => <div data-testid="image-reply-body" />,
}));
jest.mock("../../../../src/components/views/messages/MStickerBody", () => ({
__esModule: true,
default: () => <div data-testid="sticker-body" />,
}));
jest.mock("../../../../src/components/views/messages/TextualBody.tsx", () => ({
__esModule: true,
default: () => <div data-testid="textual-body" />,
}));
describe("MessageEvent", () => {
let room: Room;
let client: MatrixClient;
let event: MatrixEvent;
const renderMessageEvent = (): RenderResult => {
return render(
<MessageEvent
mxEvent={event}
onHeightChanged={jest.fn()}
permalinkCreator={new RoomPermalinkCreator(room)}
/>,
);
};
beforeEach(() => {
client = stubClient();
room = mkRoom(client, "!room:example.com");
jest.spyOn(SettingsStore, "getValue");
jest.spyOn(SettingsStore, "watchSetting");
jest.spyOn(SettingsStore, "unwatchSetting").mockImplementation(jest.fn());
});
describe("when a voice broadcast start event occurs", () => {
let result: RenderResult;
beforeEach(() => {
event = mkEvent({
event: true,
type: VoiceBroadcastInfoEventType,
user: client.getUserId()!,
room: room.roomId,
content: {
state: VoiceBroadcastInfoState.Started,
},
});
result = renderMessageEvent();
});
it("should render a VoiceBroadcast component", () => {
result.getByTestId("voice-broadcast-body");
});
});
describe("when an image with a caption is sent", () => {
let result: RenderResult;
beforeEach(() => {
event = mkEvent({
event: true,
type: EventType.RoomMessage,
user: client.getUserId()!,
room: room.roomId,
content: {
body: "caption for a test image",
format: "org.matrix.custom.html",
formatted_body: "<strong>caption for a test image</strong>",
msgtype: MsgType.Image,
filename: "image.webp",
info: {
w: 40,
h: 50,
},
url: "mxc://server/image",
},
});
result = renderMessageEvent();
});
it("should render a TextualBody and an ImageBody", () => {
fetchMock.getOnce(
"https://server/_matrix/media/v3/download/server/image",
{
body: fs.readFileSync(path.resolve(__dirname, "..", "..", "..", "images", "animated-logo.webp")),
},
{ sendAsJson: false },
);
result.getByTestId("image-body");
result.getByTestId("textual-body");
});
});
});

View file

@ -0,0 +1,54 @@
/*
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 userEvent from "@testing-library/user-event";
import MessageTimestamp from "../../../../src/components/views/messages/MessageTimestamp";
jest.mock("../../../../src/settings/SettingsStore");
describe("MessageTimestamp", () => {
// Friday Dec 17 2021, 9:09am
const nowDate = new Date("2021-12-17T08:09:00.000Z");
const HOUR_MS = 3600000;
const DAY_MS = HOUR_MS * 24;
it("should render HH:MM", () => {
const { asFragment } = render(<MessageTimestamp ts={nowDate.getTime()} />);
expect(asFragment()).toMatchInlineSnapshot(`
<DocumentFragment>
<span
aria-hidden="true"
aria-live="off"
class="mx_MessageTimestamp"
>
08:09
</span>
</DocumentFragment>
`);
});
it("should show full date & time on hover", async () => {
const { container } = render(<MessageTimestamp ts={nowDate.getTime()} />);
await userEvent.hover(container.querySelector(".mx_MessageTimestamp")!);
expect((await screen.findByRole("tooltip")).textContent).toMatchInlineSnapshot(`"Fri, Dec 17, 2021, 08:09:00"`);
});
it("should show sent & received time on hover if passed", async () => {
const { container } = render(
<MessageTimestamp ts={nowDate.getTime()} receivedTs={nowDate.getTime() + DAY_MS} />,
);
await userEvent.hover(container.querySelector(".mx_MessageTimestamp")!);
expect((await screen.findByRole("tooltip")).textContent).toMatchInlineSnapshot(
`"Sent at: Fri, Dec 17, 2021, 08:09:00Received at: Sat, Dec 18, 2021, 08:09:00"`,
);
});
});

View file

@ -0,0 +1,19 @@
/*
* Copyright 2024 New Vector Ltd.
*
* 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 { PinnedMessageBadge } from "../../../../src/components/views/messages/PinnedMessageBadge.tsx";
describe("PinnedMessageBadge", () => {
it("should render", () => {
const { asFragment } = render(<PinnedMessageBadge />);
expect(asFragment()).toMatchSnapshot();
});
});

View file

@ -0,0 +1,125 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 Beeper
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 { IContent, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import { render } from "jest-matrix-react";
import MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
import { getMockClientWithEventEmitter } from "../../../test-utils";
import ReactionsRowButton, { IProps } from "../../../../src/components/views/messages/ReactionsRowButton";
describe("ReactionsRowButton", () => {
const userId = "@alice:server";
const roomId = "!randomcharacters:aser.ver";
const mockClient = getMockClientWithEventEmitter({
mxcUrlToHttp: jest.fn().mockReturnValue("https://not.a.real.url"),
getRoom: jest.fn(),
});
const room = new Room(roomId, mockClient, userId);
const createProps = (relationContent: IContent): IProps => ({
mxEvent: new MatrixEvent({
room_id: roomId,
event_id: "$test:example.com",
content: { body: "test" },
}),
content: relationContent["m.relates_to"]?.key || "",
count: 2,
reactionEvents: [
new MatrixEvent({
type: "m.reaction",
sender: "@user1:example.com",
content: relationContent,
}),
new MatrixEvent({
type: "m.reaction",
sender: "@user2:example.com",
content: relationContent,
}),
],
customReactionImagesEnabled: true,
});
beforeEach(function () {
jest.clearAllMocks();
mockClient.credentials = { userId: userId };
mockClient.getRoom.mockImplementation((roomId: string): Room | null => {
return roomId === room.roomId ? room : null;
});
});
it("renders reaction row button emojis correctly", () => {
const props = createProps({
"m.relates_to": {
event_id: "$user2:example.com",
key: "👍",
rel_type: "m.annotation",
},
});
const root = render(
<MatrixClientContext.Provider value={mockClient}>
<ReactionsRowButton {...props} />
</MatrixClientContext.Provider>,
);
expect(root.asFragment()).toMatchSnapshot();
// Try hover and make sure that the ReactionsRowButtonTooltip works
const reactionButton = root.getByRole("button");
const event = new MouseEvent("mouseover", {
bubbles: true,
cancelable: true,
});
reactionButton.dispatchEvent(event);
expect(root.asFragment()).toMatchSnapshot();
});
it("renders reaction row button custom image reactions correctly", () => {
const props = createProps({
"com.beeper.reaction.shortcode": ":test:",
"shortcode": ":test:",
"m.relates_to": {
event_id: "$user1:example.com",
key: "mxc://example.com/123456789",
rel_type: "m.annotation",
},
});
const root = render(
<MatrixClientContext.Provider value={mockClient}>
<ReactionsRowButton {...props} />
</MatrixClientContext.Provider>,
);
expect(root.asFragment()).toMatchSnapshot();
// Try hover and make sure that the ReactionsRowButtonTooltip works
const reactionButton = root.getByRole("button");
const event = new MouseEvent("mouseover", {
bubbles: true,
cancelable: true,
});
reactionButton.dispatchEvent(event);
expect(root.asFragment()).toMatchSnapshot();
});
it("renders without a room", () => {
mockClient.getRoom.mockImplementation(() => null);
const props = createProps({});
const root = render(
<MatrixClientContext.Provider value={mockClient}>
<ReactionsRowButton {...props} />
</MatrixClientContext.Provider>,
);
expect(root.asFragment()).toMatchSnapshot();
});
});

View file

@ -0,0 +1,284 @@
/*
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 { act, render, screen, waitFor } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import { mocked } from "jest-mock";
import { EventType, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import dis from "../../../../src/dispatcher/dispatcher";
import SettingsStore from "../../../../src/settings/SettingsStore";
import {
guessServerNameFromRoomId,
RoomPredecessorTile,
} from "../../../../src/components/views/messages/RoomPredecessorTile";
import { stubClient, upsertRoomStateEvents } from "../../../test-utils/test-utils";
import { Action } from "../../../../src/dispatcher/actions";
import RoomContext from "../../../../src/contexts/RoomContext";
import { filterConsole, getRoomContext } from "../../../test-utils";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
jest.mock("../../../../src/dispatcher/dispatcher");
describe("<RoomPredecessorTile />", () => {
const userId = "@alice:server.org";
const roomId = "!room:server.org";
stubClient();
const client = mocked(MatrixClientPeg.safeGet());
function makeRoom({
createEventHasPredecessor = false,
predecessorEventExists = false,
predecessorEventHasEventId = false,
predecessorEventHasViaServers = false,
}): Room {
const room = new Room(roomId, client, userId);
const createInfo = {
type: EventType.RoomCreate,
state_key: "",
sender: userId,
room_id: roomId,
content: {},
event_id: "$create",
};
if (createEventHasPredecessor) {
createInfo.content = {
predecessor: { room_id: "old_room_id", event_id: "$tombstone_event_id" },
};
}
const createEvent = new MatrixEvent(createInfo);
upsertRoomStateEvents(room, [createEvent]);
if (predecessorEventExists) {
const predecessorInfo = {
type: EventType.RoomPredecessor,
state_key: "",
sender: userId,
room_id: roomId,
content: {
predecessor_room_id: "old_room_id_from_predecessor",
last_known_event_id: predecessorEventHasEventId
? "$tombstone_event_id_from_predecessor"
: undefined,
via_servers: predecessorEventHasViaServers ? ["a.example.com", "b.example.com"] : undefined,
},
event_id: "$predecessor",
};
const predecessorEvent = new MatrixEvent(predecessorInfo);
upsertRoomStateEvents(room, [predecessorEvent]);
}
return room;
}
beforeEach(() => {
jest.clearAllMocks();
mocked(dis.dispatch).mockReset();
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
stubClient();
});
afterAll(() => {
jest.spyOn(SettingsStore, "getValue").mockRestore();
jest.spyOn(SettingsStore, "setValue").mockRestore();
});
function renderTile(room: Room) {
// Find this room's create event (it should have one!)
const createEvent = room.currentState.getStateEvents("m.room.create")[0];
expect(createEvent).toBeTruthy();
return render(
<RoomContext.Provider value={getRoomContext(room, {})}>
<RoomPredecessorTile mxEvent={createEvent} />
</RoomContext.Provider>,
);
}
it("Renders as expected", () => {
const roomCreate = renderTile(makeRoom({ createEventHasPredecessor: true }));
expect(roomCreate.asFragment()).toMatchSnapshot();
});
it("Links to the old version of the room", () => {
renderTile(makeRoom({ createEventHasPredecessor: true }));
expect(screen.getByText("Click here to see older messages.")).toHaveAttribute(
"href",
"https://matrix.to/#/old_room_id/$tombstone_event_id",
);
});
describe("(filtering warnings about no predecessor)", () => {
filterConsole("RoomPredecessorTile unexpectedly used in a room with no predecessor.");
it("Shows an empty div if there is no predecessor", () => {
renderTile(makeRoom({}));
expect(screen.queryByText("Click here to see older messages.", { exact: false })).toBeNull();
});
});
it("Opens the old room on click", async () => {
renderTile(makeRoom({ createEventHasPredecessor: true }));
const link = screen.getByText("Click here to see older messages.");
await act(() => userEvent.click(link));
await waitFor(() =>
expect(dis.dispatch).toHaveBeenCalledWith({
action: Action.ViewRoom,
event_id: "$tombstone_event_id",
highlighted: true,
room_id: "old_room_id",
metricsTrigger: "Predecessor",
metricsViaKeyboard: false,
}),
);
});
it("Ignores m.predecessor if labs flag is off", () => {
renderTile(makeRoom({ createEventHasPredecessor: true, predecessorEventExists: true }));
expect(screen.getByText("Click here to see older messages.")).toHaveAttribute(
"href",
"https://matrix.to/#/old_room_id/$tombstone_event_id",
);
});
describe("If the predecessor room is not found", () => {
filterConsole("Failed to find predecessor room with id old_room_id");
beforeEach(() => {
mocked(MatrixClientPeg.safeGet().getRoom).mockReturnValue(null);
});
it("Shows an error if there are no via servers", () => {
renderTile(makeRoom({ createEventHasPredecessor: true, predecessorEventExists: true }));
expect(screen.getByText("Can't find the old version of this room", { exact: false })).toBeInTheDocument();
});
});
describe("When feature_dynamic_room_predecessors = true", () => {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockImplementation(
(settingName) => settingName === "feature_dynamic_room_predecessors",
);
});
afterEach(() => {
jest.spyOn(SettingsStore, "getValue").mockReset();
});
it("Uses the create event if there is no m.predecessor", () => {
renderTile(makeRoom({ createEventHasPredecessor: true }));
expect(screen.getByText("Click here to see older messages.")).toHaveAttribute(
"href",
"https://matrix.to/#/old_room_id/$tombstone_event_id",
);
});
it("Uses m.predecessor when it's there", () => {
renderTile(makeRoom({ createEventHasPredecessor: true, predecessorEventExists: true }));
expect(screen.getByText("Click here to see older messages.")).toHaveAttribute(
"href",
"https://matrix.to/#/old_room_id_from_predecessor",
);
});
it("Links to the event in the room if event ID is provided", () => {
renderTile(
makeRoom({
createEventHasPredecessor: true,
predecessorEventExists: true,
predecessorEventHasEventId: true,
}),
);
expect(screen.getByText("Click here to see older messages.")).toHaveAttribute(
"href",
"https://matrix.to/#/old_room_id_from_predecessor/$tombstone_event_id_from_predecessor",
);
});
describe("If the predecessor room is not found", () => {
filterConsole("Failed to find predecessor room with id old_room_id");
beforeEach(() => {
mocked(MatrixClientPeg.safeGet().getRoom).mockReturnValue(null);
});
it("Shows an error if there are no via servers", () => {
renderTile(makeRoom({ createEventHasPredecessor: true, predecessorEventExists: true }));
expect(
screen.getByText("Can't find the old version of this room", { exact: false }),
).toBeInTheDocument();
});
it("Shows a tile if there are via servers", () => {
renderTile(
makeRoom({
createEventHasPredecessor: true,
predecessorEventExists: true,
predecessorEventHasViaServers: true,
}),
);
expect(screen.getByText("Click here to see older messages.")).toHaveAttribute(
"href",
"https://matrix.to/#/old_room_id_from_predecessor?via=a.example.com&via=b.example.com",
);
});
it("Shows a tile linking to an event if there are via servers", () => {
renderTile(
makeRoom({
createEventHasPredecessor: true,
predecessorEventExists: true,
predecessorEventHasEventId: true,
predecessorEventHasViaServers: true,
}),
);
expect(screen.getByText("Click here to see older messages.")).toHaveAttribute(
"href",
"https://matrix.to/#/old_room_id_from_predecessor/$tombstone_event_id_from_predecessor?via=a.example.com&via=b.example.com",
);
});
});
});
});
describe("guessServerNameFromRoomId", () => {
it("Extracts the domain name from a standard room ID", () => {
expect(guessServerNameFromRoomId("!436456:example.com")).toEqual("example.com");
});
it("Extracts the domain name and port when included", () => {
expect(guessServerNameFromRoomId("!436456:example.com:8888")).toEqual("example.com:8888");
});
it("Handles an IPv4 address for server name", () => {
expect(guessServerNameFromRoomId("!436456:127.0.0.1")).toEqual("127.0.0.1");
});
it("Handles an IPv4 address and port", () => {
expect(guessServerNameFromRoomId("!436456:127.0.0.1:81")).toEqual("127.0.0.1:81");
});
it("Handles an IPv6 address for server name", () => {
expect(guessServerNameFromRoomId("!436456:::1")).toEqual("::1");
});
it("Handles an IPv6 address and port", () => {
expect(guessServerNameFromRoomId("!436456:::1:8080")).toEqual("::1:8080");
});
it("Returns null when the room ID contains no colon", () => {
expect(guessServerNameFromRoomId("!436456")).toBeNull();
});
});

View file

@ -0,0 +1,418 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-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, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { mocked, MockedObject } from "jest-mock";
import { render } from "jest-matrix-react";
import { getMockClientWithEventEmitter, mkEvent, mkMessage, mkStubRoom } from "../../../test-utils";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import * as languageHandler from "../../../../src/languageHandler";
import DMRoomMap from "../../../../src/utils/DMRoomMap";
import TextualBody from "../../../../src/components/views/messages/TextualBody";
import MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks";
import { MediaEventHelper } from "../../../../src/utils/MediaEventHelper";
const room1Id = "!room1:example.com";
const room2Id = "!room2:example.com";
const room2Name = "Room 2";
interface MkRoomTextMessageOpts {
roomId?: string;
}
const mkRoomTextMessage = (body: string, mkRoomTextMessageOpts?: MkRoomTextMessageOpts): MatrixEvent => {
return mkMessage({
msg: body,
room: mkRoomTextMessageOpts?.roomId ?? room1Id,
user: "sender",
event: true,
});
};
const mkFormattedMessage = (body: string, formattedBody: string): MatrixEvent => {
return mkMessage({
msg: body,
formattedMsg: formattedBody,
format: "org.matrix.custom.html",
room: room1Id,
user: "sender",
event: true,
});
};
describe("<TextualBody />", () => {
afterEach(() => {
jest.spyOn(MatrixClientPeg, "get").mockRestore();
jest.spyOn(global.Math, "random").mockRestore();
});
const defaultRoom = mkStubRoom(room1Id, "test room", undefined);
const otherRoom = mkStubRoom(room2Id, room2Name, undefined);
let defaultMatrixClient: MockedObject<MatrixClient>;
const defaultEvent = mkEvent({
type: "m.room.message",
room: room1Id,
user: "sender",
content: {
body: "winks",
msgtype: "m.emote",
},
event: true,
});
beforeEach(() => {
defaultMatrixClient = getMockClientWithEventEmitter({
getRoom: (roomId: string | undefined) => {
if (roomId === room1Id) return defaultRoom;
if (roomId === room2Id) return otherRoom;
return null;
},
getRooms: () => [defaultRoom, otherRoom],
getAccountData: (): MatrixEvent | undefined => undefined,
isGuest: () => false,
mxcUrlToHttp: (s: string) => s,
getUserId: () => "@user:example.com",
fetchRoomEvent: () => {
throw new Error("MockClient event not found");
},
});
mocked(defaultRoom).findEventById.mockImplementation((eventId: string) => {
if (eventId === defaultEvent.getId()) return defaultEvent;
return undefined;
});
jest.spyOn(global.Math, "random").mockReturnValue(0.123456);
});
const defaultProps = {
mxEvent: defaultEvent,
highlights: [] as string[],
highlightLink: "",
onMessageAllowed: jest.fn(),
onHeightChanged: jest.fn(),
permalinkCreator: new RoomPermalinkCreator(defaultRoom),
mediaEventHelper: {} as MediaEventHelper,
};
const getComponent = (props = {}, matrixClient: MatrixClient = defaultMatrixClient, renderingFn?: any) =>
(renderingFn ?? render)(
<MatrixClientContext.Provider value={matrixClient}>
<TextualBody {...defaultProps} {...props} />
</MatrixClientContext.Provider>,
);
it("renders m.emote correctly", () => {
DMRoomMap.makeShared(defaultMatrixClient);
const ev = mkEvent({
type: "m.room.message",
room: room1Id,
user: "sender",
content: {
body: "winks",
msgtype: "m.emote",
},
event: true,
});
const { container } = getComponent({ mxEvent: ev });
expect(container).toHaveTextContent("* sender winks");
const content = container.querySelector(".mx_EventTile_body");
expect(content).toMatchSnapshot();
});
it("renders m.notice correctly", () => {
DMRoomMap.makeShared(defaultMatrixClient);
const ev = mkEvent({
type: "m.room.message",
room: room1Id,
user: "bot_sender",
content: {
body: "this is a notice, probably from a bot",
msgtype: "m.notice",
},
event: true,
});
const { container } = getComponent({ mxEvent: ev });
expect(container).toHaveTextContent(ev.getContent().body);
const content = container.querySelector(".mx_EventTile_body");
expect(content).toMatchSnapshot();
});
describe("renders plain-text m.text correctly", () => {
beforeEach(() => {
DMRoomMap.makeShared(defaultMatrixClient);
});
it("simple message renders as expected", () => {
const ev = mkRoomTextMessage("this is a plaintext message");
const { container } = getComponent({ mxEvent: ev });
expect(container).toHaveTextContent(ev.getContent().body);
const content = container.querySelector(".mx_EventTile_body");
expect(content).toMatchSnapshot();
});
// If pills were rendered within a Portal/same shadow DOM then it'd be easier to test
it("linkification get applied correctly into the DOM", () => {
const ev = mkRoomTextMessage("Visit https://matrix.org/");
const { container } = getComponent({ mxEvent: ev });
expect(container).toHaveTextContent(ev.getContent().body);
const content = container.querySelector(".mx_EventTile_body");
expect(content).toMatchSnapshot();
});
it("should not pillify MXIDs", () => {
const ev = mkRoomTextMessage("Chat with @user:example.com");
const { container } = getComponent({ mxEvent: ev });
const content = container.querySelector(".mx_EventTile_body");
expect(content.innerHTML).toMatchInlineSnapshot(
`"Chat with <a href="https://matrix.to/#/@user:example.com" class="linkified" rel="noreferrer noopener">@user:example.com</a>"`,
);
});
it("should pillify an MXID permalink", () => {
const ev = mkRoomTextMessage("Chat with https://matrix.to/#/@user:example.com");
const { container } = getComponent({ mxEvent: ev });
const content = container.querySelector(".mx_EventTile_body");
expect(content.innerHTML).toMatchInlineSnapshot(
`"Chat with <span><bdi><a class="mx_Pill mx_UserPill mx_UserPill_me" href="https://matrix.to/#/@user:example.com"><span aria-label="Profile picture" aria-hidden="true" data-testid="avatar-img" data-type="round" data-color="2" class="_avatar_mcap2_17 mx_BaseAvatar" style="--cpd-avatar-size: 16px;"><img loading="lazy" alt="" src="mxc://avatar.url/image.png" referrerpolicy="no-referrer" class="_image_mcap2_50" data-type="round" width="16px" height="16px"></span><span class="mx_Pill_text">Member</span></a></bdi></span>"`,
);
});
it("should not pillify room aliases", () => {
const ev = mkRoomTextMessage("Visit #room:example.com");
const { container } = getComponent({ mxEvent: ev });
const content = container.querySelector(".mx_EventTile_body");
expect(content.innerHTML).toMatchInlineSnapshot(
`"Visit <a href="https://matrix.to/#/#room:example.com" class="linkified" rel="noreferrer noopener">#room:example.com</a>"`,
);
});
it("should pillify a room alias permalink", () => {
const ev = mkRoomTextMessage("Visit https://matrix.to/#/#room:example.com");
const { container } = getComponent({ mxEvent: ev });
const content = container.querySelector(".mx_EventTile_body");
expect(content.innerHTML).toMatchInlineSnapshot(
`"Visit <span><bdi><a class="mx_Pill mx_RoomPill" href="https://matrix.to/#/#room:example.com"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 24 24" class="mx_Pill_LinkIcon mx_BaseAvatar"><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"></path></svg><span class="mx_Pill_text">#room:example.com</span></a></bdi></span>"`,
);
});
it("should pillify a permalink to a message in the same room with the label »Message from Member«", () => {
const ev = mkRoomTextMessage(`Visit https://matrix.to/#/${room1Id}/${defaultEvent.getId()}`);
const { container } = getComponent({ mxEvent: ev });
const content = container.querySelector(".mx_EventTile_body");
expect(content.innerHTML.replace(defaultEvent.getId(), "%event_id%")).toMatchSnapshot();
});
it("should pillify a permalink to an unknown message in the same room with the label »Message«", () => {
const ev = mkRoomTextMessage(`Visit https://matrix.to/#/${room1Id}/!abc123`);
const { container } = getComponent({ mxEvent: ev });
const content = container.querySelector(".mx_EventTile_body");
expect(content).toMatchSnapshot();
});
it("should pillify a permalink to an event in another room with the label »Message in Room 2«", () => {
const ev = mkRoomTextMessage(`Visit https://matrix.to/#/${room2Id}/${defaultEvent.getId()}`);
const { container } = getComponent({ mxEvent: ev });
const content = container.querySelector(".mx_EventTile_body");
expect(content.innerHTML.replace(defaultEvent.getId(), "%event_id%")).toMatchSnapshot();
});
});
describe("renders formatted m.text correctly", () => {
let matrixClient: MatrixClient;
beforeEach(() => {
matrixClient = getMockClientWithEventEmitter({
getRoom: () => mkStubRoom(room1Id, "room name", undefined),
getAccountData: (): MatrixEvent | undefined => undefined,
getUserId: () => "@me:my_server",
getHomeserverUrl: () => "https://my_server/",
on: (): void => undefined,
removeListener: (): void => undefined,
isGuest: () => false,
mxcUrlToHttp: (s: string) => s,
});
DMRoomMap.makeShared(defaultMatrixClient);
});
it("italics, bold, underline and strikethrough render as expected", () => {
const ev = mkFormattedMessage(
"foo *baz* __bar__ <del>del</del> <u>u</u>",
"foo <em>baz</em> <strong>bar</strong> <del>del</del> <u>u</u>",
);
const { container } = getComponent({ mxEvent: ev }, matrixClient);
expect(container).toHaveTextContent("foo baz bar del u");
const content = container.querySelector(".mx_EventTile_body");
expect(content).toMatchSnapshot();
});
it("spoilers get injected properly into the DOM", () => {
const ev = mkFormattedMessage(
"Hey [Spoiler for movie](mxc://someserver/somefile)",
'Hey <span data-mx-spoiler="movie">the movie was awesome</span>',
);
const { container } = getComponent({ mxEvent: ev }, matrixClient);
expect(container).toHaveTextContent("Hey (movie) the movie was awesome");
const content = container.querySelector(".mx_EventTile_body");
expect(content).toMatchSnapshot();
});
it("linkification is not applied to code blocks", () => {
const ev = mkFormattedMessage(
"Visit `https://matrix.org/`\n```\nhttps://matrix.org/\n```",
"<p>Visit <code>https://matrix.org/</code></p>\n<pre>https://matrix.org/\n</pre>\n",
);
const { container } = getComponent({ mxEvent: ev }, matrixClient);
expect(container).toHaveTextContent("Visit https://matrix.org/ 1https://matrix.org/");
const content = container.querySelector(".mx_EventTile_body");
expect(content).toMatchSnapshot();
});
// If pills were rendered within a Portal/same shadow DOM then it'd be easier to test
it("pills get injected correctly into the DOM", () => {
const ev = mkFormattedMessage("Hey User", 'Hey <a href="https://matrix.to/#/@user:server">Member</a>');
const { container } = getComponent({ mxEvent: ev }, matrixClient);
expect(container).toHaveTextContent("Hey Member");
const content = container.querySelector(".mx_EventTile_body");
expect(content).toMatchSnapshot();
});
it("pills do not appear in code blocks", () => {
const ev = mkFormattedMessage(
"`@room`\n```\n@room\n```",
"<p><code>@room</code></p>\n<pre><code>@room\n</code></pre>\n",
);
const { container } = getComponent({ mxEvent: ev });
expect(container).toHaveTextContent("@room 1@room");
const content = container.querySelector(".mx_EventTile_body");
expect(content).toMatchSnapshot();
});
it("pills do not appear for event permalinks with a custom label", () => {
const ev = mkFormattedMessage(
"An [event link](https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com/" +
"$16085560162aNpaH:example.com?via=example.com) with text",
'An <a href="https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com/' +
'$16085560162aNpaH:example.com?via=example.com">event link</a> with text',
);
const { asFragment, container } = getComponent({ mxEvent: ev }, matrixClient);
expect(container).toHaveTextContent("An event link with text");
expect(asFragment()).toMatchSnapshot();
});
it("pills appear for event permalinks without a custom label", () => {
const ev = mkFormattedMessage(
"See this message https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com/$16085560162aNpaH:example.com?via=example.com",
'See this message <a href="https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com/$16085560162aNpaH:example.com?via=example.com">' +
"https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com/$16085560162aNpaH:example.com?via=example.com</a>",
);
const { asFragment } = getComponent({ mxEvent: ev }, matrixClient);
expect(asFragment()).toMatchSnapshot();
});
it("pills appear for room links with vias", () => {
const ev = mkFormattedMessage(
"A [room link](https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com" +
"?via=example.com&via=bob.com) with vias",
'A <a href="https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com' +
'?via=example.com&amp;via=bob.com">room link</a> with vias',
);
const { asFragment, container } = getComponent({ mxEvent: ev }, matrixClient);
expect(container).toHaveTextContent("A room name with vias");
expect(asFragment()).toMatchSnapshot();
});
it("pills appear for an MXID permalink", () => {
const ev = mkFormattedMessage(
"Chat with [@user:example.com](https://matrix.to/#/@user:example.com)",
'Chat with <a href="https://matrix.to/#/@user:example.com">@user:example.com</a>',
);
const { container } = getComponent({ mxEvent: ev }, matrixClient);
const content = container.querySelector(".mx_EventTile_body");
expect(content).toMatchSnapshot();
});
it("renders formatted body without html correctly", () => {
const ev = mkEvent({
type: "m.room.message",
room: "room_id",
user: "sender",
content: {
body: "escaped \\*markdown\\*",
msgtype: "m.text",
format: "org.matrix.custom.html",
formatted_body: "escaped *markdown*",
},
event: true,
});
const { container } = getComponent({ mxEvent: ev }, matrixClient);
const content = container.querySelector(".mx_EventTile_body");
expect(content).toMatchSnapshot();
});
});
it("renders url previews correctly", () => {
languageHandler.setMissingEntryGenerator((key) => key.split("|", 2)[1]);
const matrixClient = getMockClientWithEventEmitter({
getRoom: () => mkStubRoom("room_id", "room name", undefined),
getAccountData: (): MatrixClient | undefined => undefined,
getUrlPreview: (url: string) => new Promise(() => {}),
isGuest: () => false,
mxcUrlToHttp: (s: string) => s,
});
DMRoomMap.makeShared(defaultMatrixClient);
const ev = mkRoomTextMessage("Visit https://matrix.org/");
const { container, rerender } = getComponent(
{ mxEvent: ev, showUrlPreview: true, onHeightChanged: jest.fn() },
matrixClient,
);
expect(container).toHaveTextContent(ev.getContent().body);
expect(container.querySelector("a")).toHaveAttribute("href", "https://matrix.org/");
// simulate an event edit and check the transition from the old URL preview to the new one
const ev2 = mkEvent({
type: "m.room.message",
room: "room_id",
user: "sender",
content: {
"m.new_content": {
body: "Visit https://vector.im/ and https://riot.im/",
msgtype: "m.text",
},
},
event: true,
});
jest.spyOn(ev, "replacingEventDate").mockReturnValue(new Date(1993, 7, 3));
ev.makeReplaced(ev2);
getComponent(
{ mxEvent: ev, showUrlPreview: true, onHeightChanged: jest.fn(), replacingEventId: ev.getId() },
matrixClient,
rerender,
);
expect(container).toHaveTextContent(ev2.getContent()["m.new_content"].body + "(edited)");
const links = ["https://vector.im/", "https://riot.im/"];
const anchorNodes = container.querySelectorAll("a");
Array.from(anchorNodes).forEach((node, index) => {
expect(node).toHaveAttribute("href", links[index]);
});
});
});

View file

@ -0,0 +1,64 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DateSeparator renders the date separator correctly 1`] = `
<DocumentFragment>
<div
aria-label="today"
class="mx_TimelineSeparator"
role="separator"
>
<hr
role="none"
/>
<div
class="mx_DateSeparator_dateContent"
>
<h2
aria-hidden="true"
class="mx_DateSeparator_dateHeading"
>
today
</h2>
</div>
<hr
role="none"
/>
</div>
</DocumentFragment>
`;
exports[`DateSeparator when feature_jump_to_date is enabled renders the date separator correctly 1`] = `
<DocumentFragment>
<div
aria-label="Fri, Dec 17, 2021"
class="mx_TimelineSeparator"
role="separator"
>
<hr
role="none"
/>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Jump to date"
class="mx_AccessibleButton mx_DateSeparator_jumpToDateMenu mx_DateSeparator_dateContent"
data-testid="jump-to-date-separator-button"
role="button"
tabindex="0"
>
<h2
aria-hidden="true"
class="mx_DateSeparator_dateHeading"
>
Fri, Dec 17, 2021
</h2>
<div
class="mx_DateSeparator_chevron"
/>
</div>
<hr
role="none"
/>
</div>
</DocumentFragment>
`;

View file

@ -0,0 +1,50 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DecryptionFailureBody Should display "The sender has blocked you from receiving this message" 1`] = `
<div>
<div
class="mx_DecryptionFailureBody mx_EventTile_content"
>
The sender has blocked you from receiving this message because your device is unverified
</div>
</div>
`;
exports[`DecryptionFailureBody Should display "Unable to decrypt message" 1`] = `
<div>
<div
class="mx_DecryptionFailureBody mx_EventTile_content"
>
Unable to decrypt message
</div>
</div>
`;
exports[`DecryptionFailureBody should handle messages from users who change identities after verification 1`] = `
<div>
<div
class="mx_DecryptionFailureBody mx_EventTile_content mx_DecryptionFailureVerifiedIdentityChanged"
>
<span>
<svg
class="mx_Icon mx_Icon_16"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12.713 17.713A.968.968 0 0 1 12 18a.968.968 0 0 1-.713-.287A.967.967 0 0 1 11 17a.97.97 0 0 1 .287-.712A.968.968 0 0 1 12 16a.97.97 0 0 1 .713.288A.968.968 0 0 1 13 17a.97.97 0 0 1-.287.713Zm0-4A.968.968 0 0 1 12 14a.968.968 0 0 1-.713-.287A.967.967 0 0 1 11 13V9a.97.97 0 0 1 .287-.712A.968.968 0 0 1 12 8a.97.97 0 0 1 .713.288A.968.968 0 0 1 13 9v4a.97.97 0 0 1-.287.713Z"
/>
<path
clip-rule="evenodd"
d="M10.264 3.039c.767-1.344 2.705-1.344 3.472 0l8.554 14.969c.762 1.333-.2 2.992-1.736 2.992H3.446c-1.535 0-2.498-1.659-1.736-2.992l8.553-14.969ZM3.446 19 12 4.031l8.554 14.97H3.446Z"
fill-rule="evenodd"
/>
</svg>
Verified identity has changed
</span>
</div>
</div>
`;

View file

@ -0,0 +1,41 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`JumpToDatePicker renders the date picker correctly 1`] = `
<DocumentFragment>
<form
class="mx_JumpToDatePicker_form"
>
<span
class="mx_JumpToDatePicker_label"
>
Jump to date
</span>
<div
class="mx_Field mx_Field_input mx_JumpToDatePicker_datePicker"
>
<input
id="mx_Field_1"
label="Pick a date to jump to"
max="2021-12-17"
placeholder="Pick a date to jump to"
tabindex="-1"
type="date"
value="2020-07-04"
/>
<label
for="mx_Field_1"
>
Pick a date to jump to
</label>
</div>
<button
class="mx_AccessibleButton mx_JumpToDatePicker_submitButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="-1"
type="submit"
>
Go
</button>
</form>
</DocumentFragment>
`;

View file

@ -0,0 +1,22 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<MBeaconBody /> when map display is not configured renders maps unavailable error for a live beacon with location 1`] = `
<div
class="mx_MapError mx_MBeaconBody_mapError mx_MBeaconBody_mapErrorInteractive mx_MapError_isMinimised"
data-testid="map-rendering-error"
>
<div
class="mx_MapError_icon"
/>
<h3
class="mx_Heading_h3 mx_MapError_heading"
>
Unable to load map
</h3>
<p
class="mx_MapError_message"
>
This homeserver is not configured to display maps.
</p>
</div>
`;

View file

@ -0,0 +1,39 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<MFileBody/> should show a download button in file rendering type 1`] = `
<div>
<span
class="mx_MFileBody"
>
<div
class="mx_MFileBody_download"
>
<a
class="_button_i91xf_17 _has-icon_i91xf_66"
data-kind="secondary"
data-size="sm"
download="alt for a image"
href="https://server/_matrix/media/v3/download/server/image"
rel="noreferrer noopener"
role="link"
tabindex="0"
target="_blank"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 15.575c-.133 0-.258-.02-.375-.063a.877.877 0 0 1-.325-.212l-3.6-3.6a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7c.183-.183.42-.28.712-.288.292-.008.53.08.713.263L11 12.15V5c0-.283.096-.52.287-.713A.968.968 0 0 1 12 4c.283 0 .52.096.713.287.191.192.287.43.287.713v7.15l1.875-1.875c.183-.183.42-.27.713-.263.291.009.529.105.712.288a.948.948 0 0 1 .275.7.948.948 0 0 1-.275.7l-3.6 3.6c-.1.1-.208.17-.325.212a1.106 1.106 0 0 1-.375.063ZM6 20c-.55 0-1.02-.196-1.412-.587A1.926 1.926 0 0 1 4 18v-2c0-.283.096-.52.287-.713A.967.967 0 0 1 5 15c.283 0 .52.096.713.287.191.192.287.43.287.713v2h12v-2a.97.97 0 0 1 .288-.713A.968.968 0 0 1 19 15a.97.97 0 0 1 .712.287c.192.192.288.43.288.713v2c0 .55-.196 1.02-.587 1.413A1.926 1.926 0 0 1 18 20H6Z"
/>
</svg>
Download
</a>
</div>
</span>
</div>
`;

View file

@ -0,0 +1,86 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<MImageBody/> should generate a thumbnail if one isn't included for animated media 1`] = `
<div>
<div
class="mx_MImageBody"
>
<a
href="https://server/_matrix/media/v3/download/server/image"
>
<div
class="mx_MImageBody_thumbnail_container"
style="max-height: 50px; max-width: 40px;"
>
<div
class="mx_MImageBody_placeholder"
>
<div
class="mx_Spinner"
>
<div
aria-label="Loading…"
class="mx_Spinner_icon"
data-testid="spinner"
role="progressbar"
style="width: 32px; height: 32px;"
/>
</div>
</div>
<div
style="max-height: 50px; max-width: 40px;"
>
<img
alt="alt for a test image"
class="mx_MImageBody_thumbnail"
src="blob:generated-thumb"
/>
<p
class="mx_MImageBody_gifLabel"
>
GIF
</p>
</div>
<div
style="height: 50px; width: 40px;"
/>
</div>
</a>
</div>
</div>
`;
exports[`<MImageBody/> should show a thumbnail while image is being downloaded 1`] = `
<div>
<div
class="mx_MImageBody"
>
<div
class="mx_MImageBody_thumbnail_container"
style="max-height: 50px; max-width: 40px;"
>
<div
class="mx_MImageBody_placeholder"
>
<div
class="mx_Spinner"
>
<div
aria-label="Loading…"
class="mx_Spinner_icon"
data-testid="spinner"
role="progressbar"
style="width: 32px; height: 32px;"
/>
</div>
</div>
<div
style="max-height: 50px; max-width: 40px;"
/>
<div
style="height: 50px; width: 40px;"
/>
</div>
</div>
</div>
`;

View file

@ -0,0 +1,103 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`MLocationBody <MLocationBody> with error displays correct fallback content when map_style_url is misconfigured 1`] = `
<div
class="mx_EventTile_body mx_MLocationBody"
>
<span
class="mx_EventTile_tileError"
>
Unable to load map: This homeserver is not configured correctly to display maps, or the configured map server may be unreachable.
</span>
<br />
Shared a location: Found at geo:51.5076,-0.1276 at 2021-12-21T12:22+0000
</div>
`;
exports[`MLocationBody <MLocationBody> with error displays correct fallback content without error style when map_style_url is not configured 1`] = `
<div
class="mx_EventTile_body mx_MLocationBody"
>
<span
class=""
>
Unable to load map: This homeserver is not configured to display maps.
</span>
<br />
Shared a location: Found at geo:51.5076,-0.1276 at 2021-12-21T12:22+0000
</div>
`;
exports[`MLocationBody <MLocationBody> without error renders map correctly 1`] = `
<DocumentFragment>
<div
class="mx_MLocationBody"
>
<div
aria-labelledby="floating-ui-18"
class="mx_MLocationBody_map"
>
<div
class="mx_Map mx_MLocationBody_map"
id="mx_Map_mx_MLocationBody_$2_vY7Q4uEh"
>
<span>
<div
class="mx_Marker mx_Marker_defaultColor"
id="mx_MLocationBody_$2_vY7Q4uEh-marker"
>
<div
class="mx_Marker_border"
>
<div
class="mx_Marker_icon"
/>
</div>
</div>
</span>
</div>
</div>
</div>
</DocumentFragment>
`;
exports[`MLocationBody <MLocationBody> without error renders marker correctly for a self share 1`] = `
<DocumentFragment>
<div
class="mx_MLocationBody"
>
<div
aria-labelledby="floating-ui-30"
class="mx_MLocationBody_map"
>
<div
class="mx_Map mx_MLocationBody_map"
id="mx_Map_mx_MLocationBody_$3_vY7Q4uEh"
>
<span>
<div
class="mx_Marker mx_Marker_defaultColor"
id="mx_MLocationBody_$3_vY7Q4uEh-marker"
>
<div
class="mx_Marker_border"
>
<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: 36px;"
title="@user:server"
>
u
</span>
</div>
</div>
</span>
</div>
</div>
</div>
</DocumentFragment>
`;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,117 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<MPollEndBody /> when poll start event does not exist in current timeline fetches the related poll start event and displays a poll tile 1`] = `
<div>
<div
class="mx_MPollEndBody_icon"
/>
</div>
`;
exports[`<MPollEndBody /> when poll start event exists in current timeline renders an ended poll 1`] = `
<div>
<div
class="mx_MPollEndBody"
>
<span
class="mx_Caption"
>
Ended a poll
</span>
<div
class="mx_MPollBody"
>
<h2
data-testid="pollQuestion"
>
Question?
</h2>
<div
class="mx_MPollBody_allOptions"
>
<div
class="mx_PollOption mx_PollOption_ended"
data-testid="pollOption-socks"
>
<div
class="mx_PollOption_endedOption"
data-value="socks"
>
<div
class="mx_PollOption_content"
>
<div
class="mx_PollOption_optionText"
>
Socks
</div>
<div
class="mx_PollOption_optionVoteCount"
>
0 votes
</div>
</div>
</div>
<div
class="mx_PollOption_popularityBackground"
>
<div
class="mx_PollOption_popularityAmount"
style="width: 0%;"
/>
</div>
</div>
<div
class="mx_PollOption mx_PollOption_ended"
data-testid="pollOption-shoes"
>
<div
class="mx_PollOption_endedOption"
data-value="shoes"
>
<div
class="mx_PollOption_content"
>
<div
class="mx_PollOption_optionText"
>
Shoes
</div>
<div
class="mx_PollOption_optionVoteCount"
>
0 votes
</div>
</div>
</div>
<div
class="mx_PollOption_popularityBackground"
>
<div
class="mx_PollOption_popularityAmount"
style="width: 0%;"
/>
</div>
</div>
</div>
<div
class="mx_MPollBody_totalVotes"
data-testid="totalVotes"
>
Final result based on 0 votes
<div
class="mx_Spinner"
>
<div
aria-label="Loading…"
class="mx_Spinner_icon"
data-testid="spinner"
role="progressbar"
style="width: 16px; height: 16px;"
/>
</div>
</div>
</div>
</div>
</div>
`;

View file

@ -0,0 +1,50 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`MVideoBody does not crash when given a portrait image 1`] = `
<DocumentFragment>
<span
class="mx_MVideoBody"
>
<div
class="mx_MVideoBody_container"
style="max-width: 182px; max-height: 324px;"
>
<video
class="mx_MVideoBody"
controls=""
controlslist="nodownload"
poster="data:image/png;base64,00"
preload="none"
/>
<div
style="width: 182px; height: 324px;"
/>
</div>
</span>
</DocumentFragment>
`;
exports[`MVideoBody should show poster for encrypted media before downloading it 1`] = `
<DocumentFragment>
<span
class="mx_MVideoBody"
>
<div
class="mx_MVideoBody_container"
style="max-width: 40px; max-height: 50px;"
>
<video
class="mx_MVideoBody"
controls=""
controlslist="nodownload"
poster="https://server/_matrix/media/v3/download/server/encrypted-poster"
preload="none"
title="alt for a test video"
/>
<div
style="width: 40px; height: 50px;"
/>
</div>
</span>
</DocumentFragment>
`;

View file

@ -0,0 +1,22 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PinnedMessageBadge should render 1`] = `
<DocumentFragment>
<div
class="mx_PinnedMessageBadge"
>
<svg
fill="currentColor"
height="16px"
viewBox="0 0 24 24"
width="16px"
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>
Pinned message
</div>
</DocumentFragment>
`;

View file

@ -0,0 +1,120 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ReactionsRowButton renders reaction row button custom image reactions correctly 1`] = `
<DocumentFragment>
<div
aria-label="@user1:example.com and @user2:example.com reacted with :test:"
class="mx_AccessibleButton mx_ReactionsRowButton"
role="button"
tabindex="0"
>
<img
alt=":test:"
class="mx_ReactionsRowButton_content"
height="16"
src="https://not.a.real.url"
width="16"
/>
<span
aria-hidden="true"
class="mx_ReactionsRowButton_count"
>
2
</span>
</div>
</DocumentFragment>
`;
exports[`ReactionsRowButton renders reaction row button custom image reactions correctly 2`] = `
<DocumentFragment>
<div
aria-label="@user1:example.com and @user2:example.com reacted with :test:"
class="mx_AccessibleButton mx_ReactionsRowButton"
role="button"
tabindex="0"
>
<img
alt=":test:"
class="mx_ReactionsRowButton_content"
height="16"
src="https://not.a.real.url"
width="16"
/>
<span
aria-hidden="true"
class="mx_ReactionsRowButton_count"
>
2
</span>
</div>
</DocumentFragment>
`;
exports[`ReactionsRowButton renders reaction row button emojis correctly 1`] = `
<DocumentFragment>
<div
aria-label="@user1:example.com and @user2:example.com reacted with 👍"
class="mx_AccessibleButton mx_ReactionsRowButton"
role="button"
tabindex="0"
>
<span
aria-hidden="true"
class="mx_ReactionsRowButton_content"
>
👍
</span>
<span
aria-hidden="true"
class="mx_ReactionsRowButton_count"
>
2
</span>
</div>
</DocumentFragment>
`;
exports[`ReactionsRowButton renders reaction row button emojis correctly 2`] = `
<DocumentFragment>
<div
aria-label="@user1:example.com and @user2:example.com reacted with 👍"
class="mx_AccessibleButton mx_ReactionsRowButton"
role="button"
tabindex="0"
>
<span
aria-hidden="true"
class="mx_ReactionsRowButton_content"
>
👍
</span>
<span
aria-hidden="true"
class="mx_ReactionsRowButton_count"
>
2
</span>
</div>
</DocumentFragment>
`;
exports[`ReactionsRowButton renders without a room 1`] = `
<DocumentFragment>
<div
class="mx_AccessibleButton mx_ReactionsRowButton"
role="button"
tabindex="0"
>
<span
aria-hidden="true"
class="mx_ReactionsRowButton_content"
/>
<span
aria-hidden="true"
class="mx_ReactionsRowButton_count"
>
2
</span>
</div>
</DocumentFragment>
`;

View file

@ -0,0 +1,24 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<RoomPredecessorTile /> Renders as expected 1`] = `
<DocumentFragment>
<div
class="mx_EventTileBubble mx_CreateEvent"
>
<div
class="mx_EventTileBubble_title"
>
This room is a continuation of another conversation.
</div>
<div
class="mx_EventTileBubble_subtitle"
>
<a
href="https://matrix.to/#/old_room_id/$tombstone_event_id"
>
Click here to see older messages.
</a>
</div>
</div>
</DocumentFragment>
`;

View file

@ -0,0 +1,432 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<TextualBody /> renders formatted m.text correctly italics, bold, underline and strikethrough render as expected 1`] = `
<div
class="mx_EventTile_body markdown-body translate"
dir="auto"
>
foo
<em>
baz
</em>
<strong>
bar
</strong>
<del>
del
</del>
<u>
u
</u>
</div>
`;
exports[`<TextualBody /> renders formatted m.text correctly linkification is not applied to code blocks 1`] = `
<div
class="mx_EventTile_body markdown-body translate"
dir="auto"
>
<p>
Visit
<code>
https://matrix.org/
</code>
</p>
<div
class="mx_EventTile_pre_container"
>
<pre
class="mx_EventTile_collapsedCodeBlock"
>
<span
class="mx_EventTile_lineNumbers"
>
<span>
1
</span>
</span>
<code>
https://matrix.org/
</code>
<span />
</pre>
<span
class="mx_EventTile_button mx_EventTile_copyButton "
/>
</div>
</div>
`;
exports[`<TextualBody /> renders formatted m.text correctly pills appear for an MXID permalink 1`] = `
<div
class="mx_EventTile_body markdown-body translate"
dir="auto"
>
Chat with
<span>
<bdi>
<a
class="mx_Pill mx_UserPill"
href="https://matrix.to/#/@user:example.com"
>
<span
aria-hidden="true"
aria-label="Profile picture"
class="_avatar_mcap2_17 mx_BaseAvatar"
data-color="2"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 16px;"
>
<img
alt=""
class="_image_mcap2_50"
data-type="round"
height="16px"
loading="lazy"
referrerpolicy="no-referrer"
src="mxc://avatar.url/image.png"
width="16px"
/>
</span>
<span
class="mx_Pill_text"
>
Member
</span>
</a>
</bdi>
</span>
</div>
`;
exports[`<TextualBody /> renders formatted m.text correctly pills appear for event permalinks without a custom label 1`] = `
<DocumentFragment>
<div
class="mx_MTextBody mx_EventTile_content"
>
<div
class="mx_EventTile_body markdown-body translate"
dir="auto"
>
See this message
<span>
<bdi>
<a
class="mx_Pill mx_EventPill"
href="https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com/$16085560162aNpaH:example.com?via=example.com"
>
<span
aria-hidden="true"
aria-label="Avatar"
class="_avatar_mcap2_17 mx_BaseAvatar"
data-color="1"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 16px;"
>
<img
alt=""
class="_image_mcap2_50"
data-type="round"
height="16px"
loading="lazy"
referrerpolicy="no-referrer"
src="mxc://avatar.url/room.png"
width="16px"
/>
</span>
<span
class="mx_Pill_text"
>
Message in room name
</span>
</a>
</bdi>
</span>
</div>
</div>
</DocumentFragment>
`;
exports[`<TextualBody /> renders formatted m.text correctly pills appear for room links with vias 1`] = `
<DocumentFragment>
<div
class="mx_MTextBody mx_EventTile_content"
>
<div
class="mx_EventTile_body markdown-body translate"
dir="auto"
>
A
<span>
<bdi>
<a
class="mx_Pill mx_RoomPill"
href="https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com?via=example.com&via=bob.com"
>
<span
aria-hidden="true"
aria-label="Avatar"
class="_avatar_mcap2_17 mx_BaseAvatar"
data-color="1"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 16px;"
>
<img
alt=""
class="_image_mcap2_50"
data-type="round"
height="16px"
loading="lazy"
referrerpolicy="no-referrer"
src="mxc://avatar.url/room.png"
width="16px"
/>
</span>
<span
class="mx_Pill_text"
>
room name
</span>
</a>
</bdi>
</span>
with vias
</div>
</div>
</DocumentFragment>
`;
exports[`<TextualBody /> renders formatted m.text correctly pills do not appear for event permalinks with a custom label 1`] = `
<DocumentFragment>
<div
class="mx_MTextBody mx_EventTile_content"
>
<div
class="mx_EventTile_body markdown-body translate"
dir="auto"
>
An
<a
href="https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com/$16085560162aNpaH:example.com?via=example.com"
rel="noreferrer noopener"
>
event link
</a>
with text
</div>
</div>
</DocumentFragment>
`;
exports[`<TextualBody /> renders formatted m.text correctly pills do not appear in code blocks 1`] = `
<div
class="mx_EventTile_body markdown-body translate"
dir="auto"
>
<p>
<code>
@room
</code>
</p>
<div
class="mx_EventTile_pre_container"
>
<pre
class="mx_EventTile_collapsedCodeBlock"
>
<span
class="mx_EventTile_lineNumbers"
>
<span>
1
</span>
</span>
<code>
@room
</code>
<span />
</pre>
<span
class="mx_EventTile_button mx_EventTile_copyButton "
/>
</div>
</div>
`;
exports[`<TextualBody /> renders formatted m.text correctly pills get injected correctly into the DOM 1`] = `
<div
class="mx_EventTile_body markdown-body translate"
dir="auto"
>
Hey
<span>
<bdi>
<a
class="mx_Pill mx_UserPill"
href="https://matrix.to/#/@user:server"
>
<span
aria-hidden="true"
aria-label="Profile picture"
class="_avatar_mcap2_17 mx_BaseAvatar"
data-color="2"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 16px;"
>
<img
alt=""
class="_image_mcap2_50"
data-type="round"
height="16px"
loading="lazy"
referrerpolicy="no-referrer"
src="mxc://avatar.url/image.png"
width="16px"
/>
</span>
<span
class="mx_Pill_text"
>
Member
</span>
</a>
</bdi>
</span>
</div>
`;
exports[`<TextualBody /> renders formatted m.text correctly renders formatted body without html correctly 1`] = `
<div
class="mx_EventTile_body translate"
dir="auto"
>
escaped *markdown*
</div>
`;
exports[`<TextualBody /> renders formatted m.text correctly spoilers get injected properly into the DOM 1`] = `
<div
class="mx_EventTile_body markdown-body translate"
dir="auto"
>
Hey
<span>
<button
class="mx_EventTile_spoiler"
>
<span
class="mx_EventTile_spoiler_reason"
>
(movie)
</span>
 
<span
class="mx_EventTile_spoiler_content"
>
<span>
the movie was awesome
</span>
</span>
</button>
</span>
</div>
`;
exports[`<TextualBody /> renders m.emote correctly 1`] = `
<span
class="mx_EventTile_body translate"
>
winks
</span>
`;
exports[`<TextualBody /> renders m.notice correctly 1`] = `
<div
class="mx_EventTile_body translate"
dir="auto"
>
this is a notice, probably from a bot
</div>
`;
exports[`<TextualBody /> renders plain-text m.text correctly linkification get applied correctly into the DOM 1`] = `
<div
class="mx_EventTile_body translate"
dir="auto"
>
Visit
<a
class="linkified"
href="https://matrix.org/"
rel="noreferrer noopener"
target="_blank"
>
https://matrix.org/
</a>
</div>
`;
exports[`<TextualBody /> renders plain-text m.text correctly should pillify a permalink to a message in the same room with the label »Message from Member« 1`] = `"Visit <span><bdi><a class="mx_Pill mx_EventPill" href="https://matrix.to/#/!room1:example.com/%event_id%"><span aria-label="Profile picture" aria-hidden="true" data-testid="avatar-img" data-type="round" data-color="2" class="_avatar_mcap2_17 mx_BaseAvatar" style="--cpd-avatar-size: 16px;"><img loading="lazy" alt="" src="mxc://avatar.url/image.png" referrerpolicy="no-referrer" class="_image_mcap2_50" data-type="round" width="16px" height="16px"></span><span class="mx_Pill_text">Message from Member</span></a></bdi></span>"`;
exports[`<TextualBody /> renders plain-text m.text correctly should pillify a permalink to an event in another room with the label »Message in Room 2« 1`] = `"Visit <span><bdi><a class="mx_Pill mx_EventPill" href="https://matrix.to/#/!room2:example.com/%event_id%"><span aria-label="Avatar" aria-hidden="true" data-testid="avatar-img" data-type="round" data-color="2" class="_avatar_mcap2_17 mx_BaseAvatar" style="--cpd-avatar-size: 16px;"><img loading="lazy" alt="" src="mxc://avatar.url/room.png" referrerpolicy="no-referrer" class="_image_mcap2_50" data-type="round" width="16px" height="16px"></span><span class="mx_Pill_text">Message in Room 2</span></a></bdi></span>"`;
exports[`<TextualBody /> renders plain-text m.text correctly should pillify a permalink to an unknown message in the same room with the label »Message« 1`] = `
<div
class="mx_EventTile_body translate"
dir="auto"
>
Visit
<span>
<bdi>
<a
class="mx_Pill mx_EventPill"
href="https://matrix.to/#/!room1:example.com/!abc123"
>
<svg
class="mx_Pill_LinkIcon mx_BaseAvatar"
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>
<span
class="mx_Pill_text"
>
Message
</span>
</a>
</bdi>
</span>
</div>
`;
exports[`<TextualBody /> renders plain-text m.text correctly simple message renders as expected 1`] = `
<div
class="mx_EventTile_body translate"
dir="auto"
>
this is a plaintext message
</div>
`;

View file

@ -0,0 +1,25 @@
/*
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 MediaProcessingError from "../../../../../src/components/views/messages/shared/MediaProcessingError";
describe("<MediaProcessingError />", () => {
const defaultProps = {
className: "test-classname",
children: "Something went wrong",
};
const getComponent = (props = {}) => render(<MediaProcessingError {...defaultProps} {...props} />);
it("renders", () => {
const { container } = getComponent();
expect(container).toMatchSnapshot();
});
});

View file

@ -0,0 +1,16 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<MediaProcessingError /> renders 1`] = `
<div>
<span
class="test-classname"
>
<div
class="mx_MediaProcessingError_Icon"
height="16"
width="16"
/>
Something went wrong
</span>
</div>
`;