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:
@@ -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>
|
||||
`;
|
||||
Reference in New Issue
Block a user