mv element.io @types __mocks__/ debian docker module_system/ playwright res src test webapp Dockerfile .dockerignore .eslintignore .stylelintrc.cjs babel.config.cjs recorder-worklet-loader.cjs .modernizr.json components.json config.json config.sample.json package.json project.json tsconfig.json tsconfig.module_system.json jest.config.ts playwright.config.ts webpack.config.ts build_config.sample.yaml apps/web/

mkdir apps/web/scripts
mv scripts/{cleanup.sh,ci_package.sh,copy-res.ts,deploy.py,package.sh} apps/web/scripts

And a couple of gitignore tweaks

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski
2026-02-24 15:43:58 +00:00
parent e7509c92a1
commit 91a3cb03c1
3408 changed files with 28 additions and 32 deletions
@@ -0,0 +1,450 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render, screen, cleanup, fireEvent, waitFor } from "jest-matrix-react";
import { mocked, type Mocked } from "jest-mock";
import {
Room,
RoomStateEvent,
type MatrixEvent,
MatrixEventEvent,
type MatrixClient,
type RoomMember,
EventType,
RoomEvent,
type IRoomTimelineData,
type ISendEventResponse,
} from "matrix-js-sdk/src/matrix";
import { Widget } from "matrix-widget-api";
import { type IRTCNotificationContent } from "matrix-js-sdk/src/matrixrtc";
import { randomUUID } from "node:crypto";
import {
useMockedCalls,
MockedCall,
stubClient,
mkRoomMember,
setupAsyncStoreWithClient,
resetAsyncStoreWithClient,
mkEvent,
} from "../../test-utils";
import defaultDispatcher from "../../../src/dispatcher/dispatcher";
import { Action } from "../../../src/dispatcher/actions";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import { CallStore } from "../../../src/stores/CallStore";
import { WidgetMessagingStore } from "../../../src/stores/widgets/WidgetMessagingStore";
import DMRoomMap from "../../../src/utils/DMRoomMap";
import ToastStore from "../../../src/stores/ToastStore";
import {
getIncomingCallToastKey,
getNotificationEventSendTs,
IncomingCallToast,
} from "../../../src/toasts/IncomingCallToast";
import LegacyCallHandler, { AudioID } from "../../../src/LegacyCallHandler";
import { CallEvent } from "../../../src/models/Call";
import { type WidgetMessaging } from "../../../src/stores/widgets/WidgetMessaging";
describe("IncomingCallToast", () => {
useMockedCalls();
let client: Mocked<MatrixClient>;
let room: Room;
let notificationEvent: MatrixEvent;
let alice: RoomMember;
let bob: RoomMember;
let call: MockedCall;
let widget: Widget;
const dmRoomMap = {
getUserIdForRoomId: jest.fn(),
} as unknown as DMRoomMap;
const toastStore = {
dismissToast: jest.fn(),
} as unknown as Mocked<ToastStore>;
beforeEach(async () => {
stubClient();
client = mocked(MatrixClientPeg.safeGet());
const audio = document.createElement("audio");
audio.id = AudioID.Ring;
document.body.appendChild(audio);
room = new Room("!1:example.org", client, "@alice:example.org");
const ts = Date.now();
const notificationContent = {
"notification_type": "notification",
"m.relation": { rel_type: "m.reference", event_id: "$memberEventId" },
"m.mentions": { user_ids: [], room: true },
"lifetime": 3000,
"sender_ts": ts,
} as unknown as IRTCNotificationContent;
notificationEvent = mkEvent({
type: EventType.RTCNotification,
user: "@userId:matrix.org",
content: notificationContent,
room: room.roomId,
ts,
id: "$notificationEventId",
event: true,
});
alice = mkRoomMember(room.roomId, "@alice:example.org");
bob = mkRoomMember(room.roomId, "@bob:example.org");
client.getRoom.mockImplementation((roomId) => (roomId === room.roomId ? room : null));
client.getRooms.mockReturnValue([room]);
client.reEmitter.reEmit(room, [RoomStateEvent.Events]);
MockedCall.create(room, "1");
await Promise.all(
[CallStore.instance, WidgetMessagingStore.instance].map((store) =>
setupAsyncStoreWithClient(store, client),
),
);
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 WidgetMessaging);
jest.spyOn(DMRoomMap, "shared").mockReturnValue(dmRoomMap);
jest.spyOn(ToastStore, "sharedInstance").mockReturnValue(toastStore);
toastStore.dismissToast.mockReset();
});
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));
jest.restoreAllMocks();
});
const renderToast = (): string => {
const callId = randomUUID();
call.event.getContent = () =>
({
call_id: callId,
getRoomId: () => room.roomId,
}) as any;
render(
<IncomingCallToast
notificationEvent={notificationEvent}
toastKey={getIncomingCallToastKey(callId, room.roomId)}
/>,
);
return callId;
};
it("correctly shows all the information", () => {
call.participants = new Map([
[alice, new Set("a")],
[bob, new Set(["b1", "b2"])],
]);
renderToast();
screen.getByText("Video call started");
screen.getByText("Video");
screen.getByLabelText("3 people joined");
screen.getByRole("button", { name: "Join" });
screen.getByRole("button", { name: "Close" });
});
it("start ringing on ring notify event", () => {
const oldContent = notificationEvent.getContent() as IRTCNotificationContent;
(notificationEvent as unknown as { getContent: () => IRTCNotificationContent }).getContent = () => {
return { ...oldContent, notification_type: "ring" } as IRTCNotificationContent;
};
const playMock = jest.spyOn(LegacyCallHandler.instance, "play");
render(<IncomingCallToast notificationEvent={notificationEvent} toastKey="" />);
expect(playMock).toHaveBeenCalled();
});
it("correctly renders toast without a call", () => {
call.destroy();
renderToast();
screen.getByText("Video call started");
screen.getByText("Video");
screen.getByRole("button", { name: "Join" });
screen.getByRole("button", { name: "Decline" });
screen.getByRole("button", { name: "Close" });
});
it("opens the call directly and closes the toast when pressing on the join button", async () => {
const callId = renderToast();
const dispatcherSpy = jest.fn();
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
// click on the avatar (which is the example used for pressing on any area other than the buttons)
fireEvent.click(screen.getByRole("button", { name: "Join" }));
await waitFor(() =>
expect(dispatcherSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: room.roomId,
skipLobby: true,
view_call: true,
voiceOnly: false,
}),
);
await waitFor(() =>
expect(toastStore.dismissToast).toHaveBeenCalledWith(getIncomingCallToastKey(callId, room.roomId)),
);
defaultDispatcher.unregister(dispatcherRef);
});
it("opens the call lobby and closes the toast when configured like that", async () => {
const callId = renderToast();
const dispatcherSpy = jest.fn();
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
fireEvent.click(screen.getByRole("switch", {}));
// click on the avatar (which is the example used for pressing on any area other than the buttons)
fireEvent.click(screen.getByRole("button", { name: "Join" }));
await waitFor(() =>
expect(dispatcherSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: room.roomId,
skipLobby: false,
view_call: true,
voiceOnly: false,
}),
);
await waitFor(() =>
expect(toastStore.dismissToast).toHaveBeenCalledWith(getIncomingCallToastKey(callId, room.roomId)),
);
defaultDispatcher.unregister(dispatcherRef);
});
it("Dismiss toast if user starts call and skips lobby when using shift key click", async () => {
const callId = renderToast();
const dispatcherSpy = jest.fn();
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
fireEvent.click(screen.getByRole("button", { name: "Join" }), { shiftKey: true });
await waitFor(() =>
expect(dispatcherSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: room.roomId,
skipLobby: true,
view_call: true,
voiceOnly: false,
}),
);
await waitFor(() =>
expect(toastStore.dismissToast).toHaveBeenCalledWith(getIncomingCallToastKey(callId, room.roomId)),
);
defaultDispatcher.unregister(dispatcherRef);
});
it("Dismiss toast if user joins with a remote device", async () => {
const callId = renderToast();
const dispatcherSpy = jest.fn();
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
call.emit(
CallEvent.Participants,
new Map([[mkRoomMember(room.roomId, "@userId:matrix.org"), new Set(["a"])]]),
new Map(),
);
await waitFor(() =>
expect(toastStore.dismissToast).toHaveBeenCalledWith(getIncomingCallToastKey(callId, room.roomId)),
);
defaultDispatcher.unregister(dispatcherRef);
});
it("closes the toast", async () => {
const callId = renderToast();
const dispatcherSpy = jest.fn();
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
fireEvent.click(screen.getByRole("button", { name: "Close" }));
await waitFor(() =>
expect(toastStore.dismissToast).toHaveBeenCalledWith(getIncomingCallToastKey(callId, room.roomId)),
);
defaultDispatcher.unregister(dispatcherRef);
});
it("closes toast when the call lobby is viewed", async () => {
const callId = renderToast();
defaultDispatcher.dispatch({
action: Action.ViewRoom,
room_id: room.roomId,
view_call: true,
});
await waitFor(() =>
expect(toastStore.dismissToast).toHaveBeenCalledWith(getIncomingCallToastKey(callId, room.roomId)),
);
});
it("closes toast when the call event is redacted", async () => {
const callId = renderToast();
const event = room.currentState.getStateEvents(MockedCall.EVENT_TYPE, "1")!;
room.emit(MatrixEventEvent.BeforeRedaction, event, {} as unknown as MatrixEvent);
await waitFor(() =>
expect(toastStore.dismissToast).toHaveBeenCalledWith(getIncomingCallToastKey(callId, room.roomId)),
);
});
it("closes toast when the notification event is redacted", async () => {
const callId = renderToast();
room.emit(MatrixEventEvent.BeforeRedaction, notificationEvent, {} as unknown as MatrixEvent);
await waitFor(() =>
expect(toastStore.dismissToast).toHaveBeenCalledWith(getIncomingCallToastKey(callId, room.roomId)),
);
});
it("closes toast when the matrixRTC session has ended", async () => {
const callId = renderToast();
call.destroy();
await waitFor(() =>
expect(toastStore.dismissToast).toHaveBeenCalledWith(getIncomingCallToastKey(callId, room.roomId)),
);
});
it("closes toast when a decline event was received", async () => {
const callId = renderToast();
room.emit(
RoomEvent.Timeline,
mkEvent({
user: "@userId:matrix.org",
type: EventType.RTCDecline,
content: { "m.relates_to": { event_id: notificationEvent.getId()!, rel_type: "m.reference" } },
event: true,
}),
room,
undefined,
false,
{} as unknown as IRoomTimelineData,
);
await waitFor(() =>
expect(toastStore.dismissToast).toHaveBeenCalledWith(getIncomingCallToastKey(callId, room.roomId)),
);
});
it("does not close toast when a decline event for another user was received", async () => {
const callId = renderToast();
room.emit(
RoomEvent.Timeline,
mkEvent({
user: "@userIdNotMe:matrix.org",
type: EventType.RTCDecline,
content: { "m.relates_to": { event_id: notificationEvent.getId()!, rel_type: "m.reference" } },
event: true,
}),
room,
undefined,
false,
{} as unknown as IRoomTimelineData,
);
await waitFor(() =>
expect(toastStore.dismissToast).not.toHaveBeenCalledWith(getIncomingCallToastKey(callId, room.roomId)),
);
});
it("does not close toast when a decline event for another notification Event was received", async () => {
renderToast();
const callId = renderToast();
room.emit(
RoomEvent.Timeline,
mkEvent({
user: "@userId:matrix.org",
type: EventType.RTCDecline,
content: { "m.relates_to": { event_id: "$otherNotificationEventRelation", rel_type: "m.reference" } },
event: true,
}),
room,
undefined,
false,
{} as unknown as IRoomTimelineData,
);
await waitFor(() =>
expect(toastStore.dismissToast).not.toHaveBeenCalledWith(getIncomingCallToastKey(callId, room.roomId)),
);
});
it("sends a decline event when clicking the decline button and only dismiss after sending", async () => {
const callId = renderToast();
const { promise, resolve } = Promise.withResolvers<ISendEventResponse>();
client.sendRtcDecline.mockImplementation(() => {
return promise;
});
fireEvent.click(screen.getByRole("button", { name: "Decline" }));
expect(toastStore.dismissToast).not.toHaveBeenCalledWith(getIncomingCallToastKey(callId, room.roomId));
expect(client.sendRtcDecline).toHaveBeenCalledWith("!1:example.org", "$notificationEventId");
resolve({ event_id: "$declineEventId" });
await waitFor(() =>
expect(toastStore.dismissToast).toHaveBeenCalledWith(getIncomingCallToastKey(callId, room.roomId)),
);
});
it("getNotificationEventSendTs returns the correct ts", () => {
const eventOriginServerTs = mkEvent({
user: "@userId:matrix.org",
type: EventType.RTCNotification,
content: {
"m.relates_to": { event_id: notificationEvent.getId()!, rel_type: "m.reference" },
"sender_ts": 222_000,
},
event: true,
ts: 1111,
});
const eventSendTs = mkEvent({
user: "@userId:matrix.org",
type: EventType.RTCNotification,
content: {
"m.relates_to": { event_id: notificationEvent.getId()!, rel_type: "m.reference" },
"sender_ts": 2222,
},
event: true,
ts: 1111,
});
expect(getNotificationEventSendTs(eventOriginServerTs)).toBe(1111);
expect(getNotificationEventSendTs(eventSendTs)).toBe(2222);
});
});
@@ -0,0 +1,72 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { render } from "jest-matrix-react";
import { LOCAL_NOTIFICATION_SETTINGS_PREFIX, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import { MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import React from "react";
import LegacyCallHandler from "../../../src/LegacyCallHandler";
import IncomingLegacyCallToast from "../../../src/toasts/IncomingLegacyCallToast";
import DMRoomMap from "../../../src/utils/DMRoomMap";
import { getMockClientWithEventEmitter, mockClientMethodsServer, mockClientMethodsUser } from "../../test-utils";
describe("<IncomingLegacyCallToast />", () => {
const userId = "@alice:server.org";
const deviceId = "my-device";
jest.spyOn(DMRoomMap, "shared").mockReturnValue({
getUserIdForRoomId: jest.fn(),
} as unknown as DMRoomMap);
const mockClient = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
...mockClientMethodsServer(),
getRoom: jest.fn(),
});
const mockRoom = new Room("!room:server.org", mockClient, userId);
mockClient.deviceId = deviceId;
const call = new MatrixCall({ client: mockClient, roomId: mockRoom.roomId });
const defaultProps = {
call,
};
const getComponent = (props = {}) => <IncomingLegacyCallToast {...defaultProps} {...props} />;
beforeEach(() => {
jest.clearAllMocks();
mockClient.getAccountData.mockReturnValue(undefined);
mockClient.getRoom.mockReturnValue(mockRoom);
});
it("renders when silence button when call is not silenced", () => {
const { getByLabelText } = render(getComponent());
expect(getByLabelText("Silence call")).toMatchSnapshot();
});
it("renders sound on button when call is silenced", () => {
LegacyCallHandler.instance.silenceCall(call.callId);
const { getByLabelText } = render(getComponent());
expect(getByLabelText("Sound on")).toMatchSnapshot();
});
it("renders disabled silenced button when call is forced to silent", () => {
// silence local notifications -> force call ringer to silent
mockClient.getAccountData.mockImplementation((eventType) => {
if (eventType.includes(LOCAL_NOTIFICATION_SETTINGS_PREFIX.name)) {
return new MatrixEvent({
type: eventType,
content: {
is_silenced: true,
},
});
}
});
const { getByLabelText } = render(getComponent());
expect(getByLabelText("Notifications silenced")).toMatchSnapshot();
});
});
@@ -0,0 +1,316 @@
/*
Copyright 2025 Element Creations Ltd.
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { act, render, screen } from "jest-matrix-react";
import { mocked, type Mocked } from "jest-mock";
import userEvent from "@testing-library/user-event";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { type CryptoApi } from "matrix-js-sdk/src/crypto-api";
import * as SecurityManager from "../../../src/SecurityManager";
import ToastContainer from "../../../src/components/structures/ToastContainer";
import { showToast } from "../../../src/toasts/SetupEncryptionToast";
import dis from "../../../src/dispatcher/dispatcher";
import { DeviceListener } from "../../../src/device-listener";
import Modal from "../../../src/Modal";
import ConfirmKeyStorageOffDialog from "../../../src/components/views/dialogs/ConfirmKeyStorageOffDialog";
import SetupEncryptionDialog from "../../../src/components/views/dialogs/security/SetupEncryptionDialog";
import { stubClient } from "../../test-utils";
jest.mock("../../../src/dispatcher/dispatcher", () => ({
dispatch: jest.fn(),
register: jest.fn(),
unregister: jest.fn(),
}));
describe("SetupEncryptionToast", () => {
beforeEach(() => {
jest.resetAllMocks();
render(<ToastContainer />);
});
describe("Set up recovery", () => {
it("should render the toast", async () => {
act(() => showToast("set_up_recovery"));
expect(await screen.findByRole("heading", { name: "Set up recovery" })).toBeInTheDocument();
});
it("should dismiss the toast when 'Dismiss' button clicked, and remember it", async () => {
jest.spyOn(DeviceListener.sharedInstance(), "recordRecoveryDisabled");
jest.spyOn(DeviceListener.sharedInstance(), "dismissEncryptionSetup");
act(() => showToast("set_up_recovery"));
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: "Dismiss" }));
expect(DeviceListener.sharedInstance().recordRecoveryDisabled).toHaveBeenCalled();
expect(DeviceListener.sharedInstance().dismissEncryptionSetup).toHaveBeenCalled();
});
});
describe("Key storage out of sync", () => {
let client: Mocked<MatrixClient>;
beforeEach(() => {
client = mocked(stubClient());
mocked(client.getCrypto).mockReturnValue({
getSessionBackupPrivateKey: jest.fn().mockResolvedValue(null),
resetKeyBackup: jest.fn(),
checkKeyBackupAndEnable: jest.fn(),
loadSessionBackupPrivateKeyFromSecretStorage: jest.fn(),
} as unknown as CryptoApi);
});
it("should render the toast", async () => {
act(() => showToast("key_storage_out_of_sync"));
await expect(screen.findByText("Your key storage is out of sync.")).resolves.toBeInTheDocument();
});
it("should reset key backup if needed", async () => {
showToast("key_storage_out_of_sync");
jest.spyOn(SecurityManager, "accessSecretStorage").mockImplementation(
async (func = async (): Promise<void> => {}) => {
return await func();
},
);
jest.spyOn(DeviceListener.sharedInstance(), "keyStorageOutOfSyncNeedsBackupReset").mockResolvedValue(true);
const user = userEvent.setup();
await user.click(await screen.findByText("Enter recovery key"));
expect(client.getCrypto()!.resetKeyBackup).toHaveBeenCalled();
});
it("should not reset key backup if not needed", async () => {
showToast("key_storage_out_of_sync");
jest.spyOn(SecurityManager, "accessSecretStorage").mockImplementation(
async (func = async (): Promise<void> => {}) => {
return await func();
},
);
jest.spyOn(DeviceListener.sharedInstance(), "keyStorageOutOfSyncNeedsBackupReset").mockResolvedValue(false);
// if the backup key is stored in 4S
client.isKeyBackupKeyStored.mockResolvedValue({});
const user = userEvent.setup();
await user.click(await screen.findByText("Enter recovery key"));
// we shouldn't have reset the key backup, but should have fetched
// the key from 4S
expect(client.getCrypto()!.resetKeyBackup).not.toHaveBeenCalled();
expect(client.getCrypto()!.loadSessionBackupPrivateKeyFromSecretStorage).toHaveBeenCalled();
});
it("should open settings to the reset flow when 'forgot recovery key' clicked and identity reset needed", async () => {
act(() => showToast("key_storage_out_of_sync"));
jest.spyOn(DeviceListener.sharedInstance(), "keyStorageOutOfSyncNeedsCrossSigningReset").mockResolvedValue(
true,
);
const user = userEvent.setup();
await user.click(await screen.findByText("Forgot recovery key?"));
expect(dis.dispatch).toHaveBeenCalledWith({
action: "view_user_settings",
initialTabId: "USER_ENCRYPTION_TAB",
props: { initialEncryptionState: "reset_identity_forgot" },
});
});
it("should open settings to the change recovery key flow when 'forgot recovery key' clicked and identity reset not needed", async () => {
act(() => showToast("key_storage_out_of_sync"));
jest.spyOn(DeviceListener.sharedInstance(), "keyStorageOutOfSyncNeedsCrossSigningReset").mockResolvedValue(
false,
);
const user = userEvent.setup();
await user.click(await screen.findByText("Forgot recovery key?"));
expect(dis.dispatch).toHaveBeenCalledWith({
action: "view_user_settings",
initialTabId: "USER_ENCRYPTION_TAB",
props: { initialEncryptionState: "change_recovery_key" },
});
});
it("should open settings to the reset flow when recovering fails and identity reset needed", async () => {
jest.spyOn(SecurityManager, "accessSecretStorage").mockImplementation(async () => {
throw new Error("Something went wrong while recovering!");
});
jest.spyOn(DeviceListener.sharedInstance(), "keyStorageOutOfSyncNeedsCrossSigningReset").mockResolvedValue(
true,
);
act(() => showToast("key_storage_out_of_sync"));
const user = userEvent.setup();
await user.click(await screen.findByText("Enter recovery key"));
expect(dis.dispatch).toHaveBeenCalledWith({
action: "view_user_settings",
initialTabId: "USER_ENCRYPTION_TAB",
props: { initialEncryptionState: "reset_identity_sync_failed" },
});
});
it("should open settings to the change recovery key flow when recovering fails and identity reset not needed", async () => {
jest.spyOn(SecurityManager, "accessSecretStorage").mockImplementation(async () => {
throw new Error("Something went wrong while recovering!");
});
jest.spyOn(DeviceListener.sharedInstance(), "keyStorageOutOfSyncNeedsCrossSigningReset").mockResolvedValue(
false,
);
act(() => showToast("key_storage_out_of_sync"));
const user = userEvent.setup();
await user.click(await screen.findByText("Enter recovery key"));
expect(dis.dispatch).toHaveBeenCalledWith({
action: "view_user_settings",
initialTabId: "USER_ENCRYPTION_TAB",
props: { initialEncryptionState: "change_recovery_key" },
});
});
it("should dismiss the toast when the close button is clicked", async () => {
jest.spyOn(DeviceListener.sharedInstance(), "dismissEncryptionSetup");
act(() => showToast("key_storage_out_of_sync"));
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: "Close" }));
expect(DeviceListener.sharedInstance().dismissEncryptionSetup).toHaveBeenCalled();
});
});
describe("Turn on key storage", () => {
it("should render the toast", async () => {
act(() => showToast("turn_on_key_storage"));
await expect(screen.findByText("Turn on key storage")).resolves.toBeInTheDocument();
await expect(screen.findByRole("button", { name: "Dismiss" })).resolves.toBeInTheDocument();
await expect(screen.findByRole("button", { name: "Continue" })).resolves.toBeInTheDocument();
});
it("should open settings to the Encryption tab when 'Continue' clicked", async () => {
jest.spyOn(DeviceListener.sharedInstance(), "recordKeyBackupDisabled");
act(() => showToast("turn_on_key_storage"));
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: "Continue" }));
expect(dis.dispatch).toHaveBeenCalledWith({
action: "view_user_settings",
initialTabId: "USER_ENCRYPTION_TAB",
});
expect(DeviceListener.sharedInstance().recordKeyBackupDisabled).not.toHaveBeenCalled();
});
it("should open the confirm key storage off dialog when 'Dismiss' clicked", async () => {
jest.spyOn(DeviceListener.sharedInstance(), "recordKeyBackupDisabled");
// Given that as soon as the dialog opens, it closes and says "yes they clicked dismiss"
jest.spyOn(Modal, "createDialog").mockImplementation(() => {
return { finished: Promise.resolve([true]) } as any;
});
// When we show the toast, and click Dismiss
act(() => showToast("turn_on_key_storage"));
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: "Dismiss" }));
// Then the dialog was opened
expect(Modal.createDialog).toHaveBeenCalledWith(
ConfirmKeyStorageOffDialog,
undefined,
"mx_ConfirmKeyStorageOffDialog",
);
// And the backup was disabled when the dialog's onFinished was called
expect(DeviceListener.sharedInstance().recordKeyBackupDisabled).toHaveBeenCalledTimes(1);
});
});
describe("Verify this session", () => {
it("should render the toast", async () => {
act(() => showToast("verify_this_session"));
await expect(screen.findByText("Verify this session")).resolves.toBeInTheDocument();
await expect(screen.findByRole("button", { name: "Later" })).resolves.toBeInTheDocument();
await expect(screen.findByRole("button", { name: "Verify" })).resolves.toBeInTheDocument();
});
it("should dismiss the toast when 'Later' button clicked, and remember it", async () => {
jest.spyOn(DeviceListener.sharedInstance(), "dismissEncryptionSetup");
act(() => showToast("verify_this_session"));
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: "Later" }));
expect(DeviceListener.sharedInstance().dismissEncryptionSetup).toHaveBeenCalled();
});
it("should open the verification dialog when 'Verify' clicked", async () => {
jest.spyOn(Modal, "createDialog");
// When we show the toast, and click Verify
act(() => showToast("verify_this_session"));
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: "Verify" }));
// Then the dialog was opened
expect(Modal.createDialog).toHaveBeenCalledWith(SetupEncryptionDialog, {}, undefined, false, true);
});
});
describe("Identity needs reset", () => {
it("should render the toast", async () => {
act(() => showToast("identity_needs_reset"));
await expect(screen.findByText("Your key storage is out of sync.")).resolves.toBeInTheDocument();
await expect(
screen.findByText(
"You have to reset your cryptographic identity in order to ensure access to your message history",
),
).resolves.toBeInTheDocument();
await expect(screen.findByRole("button", { name: "Continue with reset" })).resolves.toBeInTheDocument();
});
it("should open settings to the reset flow when 'Continue with reset' clicked", async () => {
act(() => showToast("identity_needs_reset"));
const user = userEvent.setup();
await user.click(await screen.findByText("Continue with reset"));
expect(dis.dispatch).toHaveBeenCalledWith({
action: "view_user_settings",
initialTabId: "USER_ENCRYPTION_TAB",
props: { initialEncryptionState: "reset_identity_cant_recover" },
});
});
});
});
@@ -0,0 +1,95 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render, type RenderResult, screen } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import { mocked, type Mocked } from "jest-mock";
import { type IMyDevice, type MatrixClient } from "matrix-js-sdk/src/matrix";
import { type CryptoApi, DeviceVerificationStatus } from "matrix-js-sdk/src/crypto-api";
import dis from "../../../src/dispatcher/dispatcher";
import { showToast } from "../../../src/toasts/UnverifiedSessionToast";
import { filterConsole, flushPromises, stubClient } from "../../test-utils";
import ToastContainer from "../../../src/components/structures/ToastContainer";
import { Action } from "../../../src/dispatcher/actions";
import { DeviceListener } from "../../../src/device-listener";
describe("UnverifiedSessionToast", () => {
const otherDevice: IMyDevice = {
device_id: "ABC123",
};
let client: Mocked<MatrixClient>;
let renderResult: RenderResult;
filterConsole("Dismissing unverified sessions: ABC123");
beforeAll(() => {
client = mocked(stubClient());
client.getDevice.mockImplementation(async (deviceId: string) => {
if (deviceId === otherDevice.device_id) {
return otherDevice;
}
throw new Error(`Unknown device ${deviceId}`);
});
client.getCrypto.mockReturnValue({
getDeviceVerificationStatus: jest
.fn()
.mockResolvedValue(new DeviceVerificationStatus({ crossSigningVerified: true })),
} as unknown as CryptoApi);
jest.spyOn(dis, "dispatch");
jest.spyOn(DeviceListener.sharedInstance(), "dismissUnverifiedSessions");
});
beforeEach(() => {
renderResult = render(<ToastContainer />);
});
describe("when rendering the toast", () => {
beforeEach(async () => {
showToast(otherDevice.device_id);
await flushPromises();
});
const itShouldDismissTheDevice = () => {
it("should dismiss the device", () => {
expect(DeviceListener.sharedInstance().dismissUnverifiedSessions).toHaveBeenCalledWith([
otherDevice.device_id,
]);
});
};
it("should render as expected", async () => {
await expect(screen.findByText("New login. Was this you?")).resolves.toBeInTheDocument();
expect(renderResult.baseElement).toMatchSnapshot();
});
describe("and confirming the login", () => {
beforeEach(async () => {
await userEvent.click(screen.getByRole("button", { name: "Yes, it was me" }));
});
itShouldDismissTheDevice();
});
describe("and dismissing the login", () => {
beforeEach(async () => {
await userEvent.click(screen.getByRole("button", { name: "No" }));
});
itShouldDismissTheDevice();
it("should show the device settings", () => {
expect(dis.dispatch).toHaveBeenCalledWith({
action: Action.ViewUserDeviceSettings,
});
});
});
});
});
@@ -0,0 +1,69 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`<IncomingLegacyCallToast /> renders disabled silenced button when call is forced to silent 1`] = `
<div
aria-disabled="true"
aria-label="Notifications silenced"
class="mx_AccessibleButton mx_IncomingLegacyCallToast_iconButton mx_AccessibleButton_disabled"
disabled=""
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3.5 3.5a1 1 0 1 0-1.414 1.414L5.172 8H5a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h2l3.293 3.293c.63.63 1.707.184 1.707-.707v-3.758l7.086 7.086A1 1 0 0 0 20.5 20.5l-2.136-2.136.003-.003-1.414-1.414-.003.003-1.414-1.414.003-.003-1.415-1.415-.002.003L12 12v-.006L7.503 7.497 7.5 7.5zm11.496 8.662 1.661 1.66c.222-.564.343-1.18.343-1.822 0-1.38-.56-2.632-1.464-3.536a1 1 0 1 0-1.414 1.414 3 3 0 0 1 .874 2.284m3.164 3.165 1.462 1.46A8.96 8.96 0 0 0 21 12a8.98 8.98 0 0 0-2.636-6.364A1 1 0 0 0 16.95 7.05 6.98 6.98 0 0 1 19 12a7 7 0 0 1-.84 3.326M8.917 6.083 12 9.166V5.414c0-.89-1.077-1.337-1.707-.707z"
/>
</svg>
</div>
`;
exports[`<IncomingLegacyCallToast /> renders sound on button when call is silenced 1`] = `
<div
aria-label="Sound on"
class="mx_AccessibleButton mx_IncomingLegacyCallToast_iconButton"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3.5 3.5a1 1 0 1 0-1.414 1.414L5.172 8H5a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h2l3.293 3.293c.63.63 1.707.184 1.707-.707v-3.758l7.086 7.086A1 1 0 0 0 20.5 20.5l-2.136-2.136.003-.003-1.414-1.414-.003.003-1.414-1.414.003-.003-1.415-1.415-.002.003L12 12v-.006L7.503 7.497 7.5 7.5zm11.496 8.662 1.661 1.66c.222-.564.343-1.18.343-1.822 0-1.38-.56-2.632-1.464-3.536a1 1 0 1 0-1.414 1.414 3 3 0 0 1 .874 2.284m3.164 3.165 1.462 1.46A8.96 8.96 0 0 0 21 12a8.98 8.98 0 0 0-2.636-6.364A1 1 0 0 0 16.95 7.05 6.98 6.98 0 0 1 19 12a7 7 0 0 1-.84 3.326M8.917 6.083 12 9.166V5.414c0-.89-1.077-1.337-1.707-.707z"
/>
</svg>
</div>
`;
exports[`<IncomingLegacyCallToast /> renders when silence button when call is not silenced 1`] = `
<div
aria-label="Silence call"
class="mx_AccessibleButton mx_IncomingLegacyCallToast_iconButton"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 14v-4a2 2 0 0 1 2-2h2l3.293-3.293c.63-.63 1.707-.184 1.707.707v13.172c0 .89-1.077 1.337-1.707.707L7 16H5a2 2 0 0 1-2-2m11.122-5.536a1 1 0 0 1 1.414 0A5 5 0 0 1 17 12c0 1.38-.56 2.632-1.464 3.536a1 1 0 0 1-1.415-1.415 3 3 0 0 0 .88-2.121c0-.829-.335-1.577-.88-2.121a1 1 0 0 1 0-1.415"
/>
<path
d="M16.95 5.636a1 1 0 0 1 1.414 0A8.98 8.98 0 0 1 21 12a8.98 8.98 0 0 1-2.636 6.364 1 1 0 0 1-1.414-1.414A6.98 6.98 0 0 0 19 12a6.98 6.98 0 0 0-2.05-4.95 1 1 0 0 1 0-1.414"
/>
</svg>
</div>
`;
@@ -0,0 +1,86 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`UnverifiedSessionToast when rendering the toast should render as expected 1`] = `
<body>
<div>
<div
class="mx_ToastContainer"
role="alert"
>
<div
class="mx_Toast_toast mx_Toast_hasIcon"
>
<svg
color="var(--cpd-color-icon-critical-primary)"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 17q.424 0 .713-.288A.97.97 0 0 0 13 16a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 15a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 16q0 .424.287.712.288.288.713.288m0-4q.424 0 .713-.287A.97.97 0 0 0 13 12V8a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 7a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 8v4q0 .424.287.713.288.287.713.287m0 9a9.7 9.7 0 0 1-3.9-.788 10.1 10.1 0 0 1-3.175-2.137q-1.35-1.35-2.137-3.175A9.7 9.7 0 0 1 2 12q0-2.075.788-3.9a10.1 10.1 0 0 1 2.137-3.175q1.35-1.35 3.175-2.137A9.7 9.7 0 0 1 12 2q2.075 0 3.9.788a10.1 10.1 0 0 1 3.175 2.137q1.35 1.35 2.137 3.175A9.7 9.7 0 0 1 22 12a9.7 9.7 0 0 1-.788 3.9 10.1 10.1 0 0 1-2.137 3.175q-1.35 1.35-3.175 2.137A9.7 9.7 0 0 1 12 22"
/>
</svg>
<div
class="mx_Toast_title"
>
<h2
class="_typography_6v6n8_153 _font-body-lg-semibold_6v6n8_74"
>
New login. Was this you?
</h2>
</div>
<div
class="mx_Toast_body"
>
<div>
<div
class="mx_Toast_description"
>
<div
class="mx_Toast_detail"
>
<span
data-testid="device-metadata-isVerified"
>
Verified
</span>
·
<span
data-testid="device-metadata-deviceId"
>
ABC123
</span>
</div>
</div>
<div
aria-live="off"
class="mx_Toast_buttons"
>
<button
class="_button_13vu4_8 _destructive_13vu4_110"
data-kind="secondary"
data-size="sm"
role="button"
tabindex="0"
>
No
</button>
<button
class="_button_13vu4_8"
data-kind="primary"
data-size="sm"
role="button"
tabindex="0"
>
Yes, it was me
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</body>
`;