From 27a43e860a715821551898d0e8c68ce342404870 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 12 Nov 2024 21:19:11 +0000 Subject: [PATCH] Use React Suspense when rendering async modals (#28386) * Use React Suspense when rendering async modals Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Fix test Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Improve coverage Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Improve coverage Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Improve coverage Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Update src/Modal.tsx --------- Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/AddThreepid.ts | 14 +- src/AsyncWrapper.tsx | 53 ++------ src/Modal.tsx | 73 ++++------- src/SecurityManager.ts | 8 +- .../security/NewRecoveryMethodDialog.tsx | 2 +- .../security/RecoveryMethodRemovedDialog.tsx | 8 +- src/components/structures/MatrixChat.tsx | 16 +-- src/components/views/dialogs/LogoutDialog.tsx | 16 +-- .../views/settings/CryptographyPanel.tsx | 16 +-- .../views/settings/EventIndexPanel.tsx | 10 +- .../views/settings/SecureBackupPanel.tsx | 9 +- test/unit-tests/SecurityManager-test.ts | 23 ++++ .../RecoveryMethodRemovedDialog-test.tsx | 33 +++++ .../components/structures/MatrixChat-test.tsx | 23 +++- .../views/dialogs/LogoutDialog-test.tsx | 5 +- .../settings/AddRemoveThreepids-test.tsx | 123 +++++++++++++++++- .../views/settings/CryptographyPanel-test.tsx | 32 ++++- 17 files changed, 306 insertions(+), 158 deletions(-) create mode 100644 test/unit-tests/async-components/dialogs/security/RecoveryMethodRemovedDialog-test.tsx diff --git a/src/AddThreepid.ts b/src/AddThreepid.ts index 34ba9d51ed..5232132535 100644 --- a/src/AddThreepid.ts +++ b/src/AddThreepid.ts @@ -10,13 +10,13 @@ Please see LICENSE files in the repository root for full details. import { IAddThreePidOnlyBody, - IAuthData, IRequestMsisdnTokenResponse, IRequestTokenResponse, MatrixClient, MatrixError, HTTPError, IThreepid, + UIAResponse, } from "matrix-js-sdk/src/matrix"; import Modal from "./Modal"; @@ -179,7 +179,9 @@ export default class AddThreepid { * with a "message" property which contains a human-readable message detailing why * the request failed. */ - public async checkEmailLinkClicked(): Promise<[success?: boolean, result?: IAuthData | Error | null]> { + public async checkEmailLinkClicked(): Promise< + [success?: boolean, result?: UIAResponse | Error | null] + > { try { if (this.bind) { const authClient = new IdentityAuthClient(); @@ -220,7 +222,7 @@ export default class AddThreepid { continueKind: "primary", }, }; - const { finished } = Modal.createDialog(InteractiveAuthDialog<{}>, { + const { finished } = Modal.createDialog(InteractiveAuthDialog, { title: _t("settings|general|add_email_dialog_title"), matrixClient: this.matrixClient, authData: err.data, @@ -263,7 +265,9 @@ export default class AddThreepid { * with a "message" property which contains a human-readable message detailing why * the request failed. */ - public async haveMsisdnToken(msisdnToken: string): Promise<[success?: boolean, result?: IAuthData | Error | null]> { + public async haveMsisdnToken( + msisdnToken: string, + ): Promise<[success?: boolean, result?: UIAResponse | Error | null]> { const authClient = new IdentityAuthClient(); if (this.submitUrl) { @@ -319,7 +323,7 @@ export default class AddThreepid { continueKind: "primary", }, }; - const { finished } = Modal.createDialog(InteractiveAuthDialog<{}>, { + const { finished } = Modal.createDialog(InteractiveAuthDialog, { title: _t("settings|general|add_msisdn_dialog_title"), matrixClient: this.matrixClient, authData: err.data, diff --git a/src/AsyncWrapper.tsx b/src/AsyncWrapper.tsx index cec814df17..2a04d804b7 100644 --- a/src/AsyncWrapper.tsx +++ b/src/AsyncWrapper.tsx @@ -6,24 +6,19 @@ 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, { ComponentType, PropsWithChildren } from "react"; -import { logger } from "matrix-js-sdk/src/logger"; +import React, { ReactNode, Suspense } from "react"; import { _t } from "./languageHandler"; import BaseDialog from "./components/views/dialogs/BaseDialog"; import DialogButtons from "./components/views/elements/DialogButtons"; import Spinner from "./components/views/elements/Spinner"; -type AsyncImport = { default: T }; - interface IProps { - // A promise which resolves with the real component - prom: Promise | AsyncImport>>; onFinished(): void; + children: ReactNode; } interface IState { - component?: ComponentType>; error?: Error; } @@ -32,56 +27,26 @@ interface IState { * spinner until the real component loads. */ export default class AsyncWrapper extends React.Component { - private unmounted = false; + public static getDerivedStateFromError(error: Error): IState { + return { error }; + } public state: IState = {}; - public componentDidMount(): void { - this.unmounted = false; - this.props.prom - .then((result) => { - if (this.unmounted) return; - - // Take the 'default' member if it's there, then we support - // passing in just an import()ed module, since ES6 async import - // always returns a module *namespace*. - const component = (result as AsyncImport).default - ? (result as AsyncImport).default - : (result as ComponentType); - this.setState({ component }); - }) - .catch((e) => { - logger.warn("AsyncWrapper promise failed", e); - this.setState({ error: e }); - }); - } - - public componentWillUnmount(): void { - this.unmounted = true; - } - - private onWrapperCancelClick = (): void => { - this.props.onFinished(); - }; - public render(): React.ReactNode { - if (this.state.component) { - const Component = this.state.component; - return ; - } else if (this.state.error) { + if (this.state.error) { return ( {_t("failed_load_async_component")} ); - } else { - // show a spinner until the component is loaded. - return ; } + + return }>{this.props.children}; } } diff --git a/src/Modal.tsx b/src/Modal.tsx index 076c0987e7..2aefdccb46 100644 --- a/src/Modal.tsx +++ b/src/Modal.tsx @@ -136,32 +136,6 @@ export class ModalManager extends TypedEventEmitter 0; } - public createDialog( - Element: C, - props?: ComponentProps, - className?: string, - isPriorityModal = false, - isStaticModal = false, - options: IOptions = {}, - ): IHandle { - return this.createDialogAsync( - Promise.resolve(Element), - props, - className, - isPriorityModal, - isStaticModal, - options, - ); - } - - public appendDialog( - Element: C, - props?: ComponentProps, - className?: string, - ): IHandle { - return this.appendDialogAsync(Promise.resolve(Element), props, className); - } - /** * DEPRECATED. * This is used only for tests. They should be using forceCloseAllModals but that @@ -196,8 +170,11 @@ export class ModalManager extends TypedEventEmitter( - prom: Promise, + Component: C, props?: ComponentProps, className?: string, options?: IOptions, @@ -222,9 +199,12 @@ export class ModalManager extends TypedEventEmitter; + // Typescript doesn't like us passing props as any here, but we know that they are well typed due to the rigorous generics. + modal.elem = ( + + + + ); modal.close = closeDialog; return { modal, closeDialog, onFinishedProm }; @@ -291,29 +271,30 @@ export class ModalManager extends TypedEventEmitter'], cb); * } * - * @param {Promise} prom a promise which resolves with a React component - * which will be displayed as the modal view. + * @param component The component to render as a dialog. This component must accept an `onFinished` prop function as + * per the type {@link ComponentType}. If loading a component with esoteric dependencies consider + * using React.lazy to async load the component. + * e.g. `lazy(() => import('./MyComponent'))` * - * @param {Object} props properties to pass to the displayed - * component. (We will also pass an 'onFinished' property.) + * @param props properties to pass to the displayed component. (We will also pass an 'onFinished' property.) * - * @param {String} className CSS class to apply to the modal wrapper + * @param className CSS class to apply to the modal wrapper * - * @param {boolean} isPriorityModal if true, this modal will be displayed regardless + * @param isPriorityModal if true, this modal will be displayed regardless * of other modals that are currently in the stack. * Also, when closed, all modals will be removed * from the stack. - * @param {boolean} isStaticModal if true, this modal will be displayed under other + * @param isStaticModal if true, this modal will be displayed under other * modals in the stack. When closed, all modals will * also be removed from the stack. This is not compatible * with being a priority modal. Only one modal can be * static at a time. - * @param {Object} options? extra options for the dialog - * @param {onBeforeClose} options.onBeforeClose a callback to decide whether to close the dialog - * @returns {object} Object with 'close' parameter being a function that will close the dialog + * @param options? extra options for the dialog + * @param options.onBeforeClose a callback to decide whether to close the dialog + * @returns Object with 'close' parameter being a function that will close the dialog */ - public createDialogAsync( - prom: Promise, + public createDialog( + component: C, props?: ComponentProps, className?: string, isPriorityModal = false, @@ -321,7 +302,7 @@ export class ModalManager extends TypedEventEmitter = {}, ): IHandle { const beforeModal = this.getCurrentModal(); - const { modal, closeDialog, onFinishedProm } = this.buildModal(prom, props, className, options); + const { modal, closeDialog, onFinishedProm } = this.buildModal(component, props, className, options); if (isPriorityModal) { // XXX: This is destructive this.priorityModal = modal; @@ -341,13 +322,13 @@ export class ModalManager extends TypedEventEmitter( - prom: Promise, + public appendDialog( + component: C, props?: ComponentProps, className?: string, ): IHandle { const beforeModal = this.getCurrentModal(); - const { modal, closeDialog, onFinishedProm } = this.buildModal(prom, props, className, {}); + const { modal, closeDialog, onFinishedProm } = this.buildModal(component, props, className, {}); this.modals.push(modal); diff --git a/src/SecurityManager.ts b/src/SecurityManager.ts index 4717404222..cf8d40acc8 100644 --- a/src/SecurityManager.ts +++ b/src/SecurityManager.ts @@ -6,11 +6,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ +import { lazy } from "react"; import { ICryptoCallbacks, SecretStorage } from "matrix-js-sdk/src/matrix"; import { deriveRecoveryKeyFromPassphrase, decodeRecoveryKey } from "matrix-js-sdk/src/crypto-api"; import { logger } from "matrix-js-sdk/src/logger"; -import type CreateSecretStorageDialog from "./async-components/views/dialogs/security/CreateSecretStorageDialog"; import Modal from "./Modal"; import { MatrixClientPeg } from "./MatrixClientPeg"; import { _t } from "./languageHandler"; @@ -232,10 +232,8 @@ async function doAccessSecretStorage(func: () => Promise, forceReset: bool if (createNew) { // This dialog calls bootstrap itself after guiding the user through // passphrase creation. - const { finished } = Modal.createDialogAsync( - import("./async-components/views/dialogs/security/CreateSecretStorageDialog") as unknown as Promise< - typeof CreateSecretStorageDialog - >, + const { finished } = Modal.createDialog( + lazy(() => import("./async-components/views/dialogs/security/CreateSecretStorageDialog")), { forceReset, }, diff --git a/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.tsx b/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.tsx index ac18039749..69fc4b4814 100644 --- a/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.tsx +++ b/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.tsx @@ -28,7 +28,7 @@ interface NewRecoveryMethodDialogProps { onFinished(): void; } -// Export as default instead of a named export so that it can be dynamically imported with `Modal.createDialogAsync` +// Export as default instead of a named export so that it can be dynamically imported with React lazy /** * Dialog to inform the user that a new recovery method has been detected. diff --git a/src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog.tsx b/src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog.tsx index aec447735e..b1a6ebafc7 100644 --- a/src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog.tsx +++ b/src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog.tsx @@ -7,11 +7,11 @@ 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 React, { lazy } from "react"; import dis from "../../../../dispatcher/dispatcher"; import { _t } from "../../../../languageHandler"; -import Modal, { ComponentType } from "../../../../Modal"; +import Modal from "../../../../Modal"; import { Action } from "../../../../dispatcher/actions"; import BaseDialog from "../../../../components/views/dialogs/BaseDialog"; import DialogButtons from "../../../../components/views/elements/DialogButtons"; @@ -28,8 +28,8 @@ export default class RecoveryMethodRemovedDialog extends React.PureComponent { this.props.onFinished(); - Modal.createDialogAsync( - import("./CreateKeyBackupDialog") as unknown as Promise, + Modal.createDialog( + lazy(() => import("./CreateKeyBackupDialog")), undefined, undefined, /* priority = */ false, diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 80a648b5d5..afd444c952 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import React, { createRef } from "react"; +import React, { createRef, lazy } from "react"; import { ClientEvent, createClient, @@ -28,8 +28,6 @@ import { TooltipProvider } from "@vector-im/compound-web"; // what-input helps improve keyboard accessibility import "what-input"; -import type NewRecoveryMethodDialog from "../../async-components/views/dialogs/security/NewRecoveryMethodDialog"; -import type RecoveryMethodRemovedDialog from "../../async-components/views/dialogs/security/RecoveryMethodRemovedDialog"; import PosthogTrackers from "../../PosthogTrackers"; import { DecryptionFailureTracker } from "../../DecryptionFailureTracker"; import { IMatrixClientCreds, MatrixClientPeg } from "../../MatrixClientPeg"; @@ -1649,16 +1647,12 @@ export default class MatrixChat extends React.PureComponent { } if (haveNewVersion) { - Modal.createDialogAsync( - import( - "../../async-components/views/dialogs/security/NewRecoveryMethodDialog" - ) as unknown as Promise, + Modal.createDialog( + lazy(() => import("../../async-components/views/dialogs/security/NewRecoveryMethodDialog")), ); } else { - Modal.createDialogAsync( - import( - "../../async-components/views/dialogs/security/RecoveryMethodRemovedDialog" - ) as unknown as Promise, + Modal.createDialog( + lazy(() => import("../../async-components/views/dialogs/security/RecoveryMethodRemovedDialog")), ); } }); diff --git a/src/components/views/dialogs/LogoutDialog.tsx b/src/components/views/dialogs/LogoutDialog.tsx index 51dc664fb4..cdfaf0e89b 100644 --- a/src/components/views/dialogs/LogoutDialog.tsx +++ b/src/components/views/dialogs/LogoutDialog.tsx @@ -7,12 +7,10 @@ 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 React, { lazy } from "react"; import { logger } from "matrix-js-sdk/src/logger"; import { MatrixClient } from "matrix-js-sdk/src/matrix"; -import type CreateKeyBackupDialog from "../../../async-components/views/dialogs/security/CreateKeyBackupDialog"; -import type ExportE2eKeysDialog from "../../../async-components/views/dialogs/security/ExportE2eKeysDialog"; import Modal from "../../../Modal"; import dis from "../../../dispatcher/dispatcher"; import { _t } from "../../../languageHandler"; @@ -116,10 +114,8 @@ export default class LogoutDialog extends React.Component { } private onExportE2eKeysClicked = (): void => { - Modal.createDialogAsync( - import("../../../async-components/views/dialogs/security/ExportE2eKeysDialog") as unknown as Promise< - typeof ExportE2eKeysDialog - >, + Modal.createDialog( + lazy(() => import("../../../async-components/views/dialogs/security/ExportE2eKeysDialog")), { matrixClient: MatrixClientPeg.safeGet(), }, @@ -147,10 +143,8 @@ export default class LogoutDialog extends React.Component { /* static = */ true, ); } else { - Modal.createDialogAsync( - import("../../../async-components/views/dialogs/security/CreateKeyBackupDialog") as unknown as Promise< - typeof CreateKeyBackupDialog - >, + Modal.createDialog( + lazy(() => import("../../../async-components/views/dialogs/security/CreateKeyBackupDialog")), undefined, undefined, /* priority = */ false, diff --git a/src/components/views/settings/CryptographyPanel.tsx b/src/components/views/settings/CryptographyPanel.tsx index 850e5c7ff5..b418c0b05d 100644 --- a/src/components/views/settings/CryptographyPanel.tsx +++ b/src/components/views/settings/CryptographyPanel.tsx @@ -6,11 +6,9 @@ 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 React, { lazy } from "react"; import { logger } from "matrix-js-sdk/src/logger"; -import type ExportE2eKeysDialog from "../../../async-components/views/dialogs/security/ExportE2eKeysDialog"; -import type ImportE2eKeysDialog from "../../../async-components/views/dialogs/security/ImportE2eKeysDialog"; import { _t } from "../../../languageHandler"; import Modal from "../../../Modal"; import AccessibleButton from "../elements/AccessibleButton"; @@ -129,19 +127,15 @@ export default class CryptographyPanel extends React.Component { } private onExportE2eKeysClicked = (): void => { - Modal.createDialogAsync( - import("../../../async-components/views/dialogs/security/ExportE2eKeysDialog") as unknown as Promise< - typeof ExportE2eKeysDialog - >, + Modal.createDialog( + lazy(() => import("../../../async-components/views/dialogs/security/ExportE2eKeysDialog")), { matrixClient: this.context }, ); }; private onImportE2eKeysClicked = (): void => { - Modal.createDialogAsync( - import("../../../async-components/views/dialogs/security/ImportE2eKeysDialog") as unknown as Promise< - typeof ImportE2eKeysDialog - >, + Modal.createDialog( + lazy(() => import("../../../async-components/views/dialogs/security/ImportE2eKeysDialog")), { matrixClient: this.context }, ); }; diff --git a/src/components/views/settings/EventIndexPanel.tsx b/src/components/views/settings/EventIndexPanel.tsx index d2ade3571c..0051c4dc3a 100644 --- a/src/components/views/settings/EventIndexPanel.tsx +++ b/src/components/views/settings/EventIndexPanel.tsx @@ -6,7 +6,7 @@ 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 React, { lazy } from "react"; import { _t } from "../../../languageHandler"; import SdkConfig from "../../../SdkConfig"; @@ -94,14 +94,12 @@ export default class EventIndexPanel extends React.Component<{}, IState> { } private onManage = async (): Promise => { - Modal.createDialogAsync( - // @ts-ignore: TS doesn't seem to like the type of this now that it - // has also been converted to TS as well, but I can't figure out why... - import("../../../async-components/views/dialogs/eventindex/ManageEventIndexDialog"), + Modal.createDialog( + lazy(() => import("../../../async-components/views/dialogs/eventindex/ManageEventIndexDialog")), { onFinished: () => {}, }, - null, + undefined, /* priority = */ false, /* static = */ true, ); diff --git a/src/components/views/settings/SecureBackupPanel.tsx b/src/components/views/settings/SecureBackupPanel.tsx index 6a855c8ea8..db165eb115 100644 --- a/src/components/views/settings/SecureBackupPanel.tsx +++ b/src/components/views/settings/SecureBackupPanel.tsx @@ -7,11 +7,10 @@ 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, { ReactNode } from "react"; +import React, { lazy, ReactNode } from "react"; import { CryptoEvent, BackupTrustInfo, KeyBackupInfo } from "matrix-js-sdk/src/crypto-api"; import { logger } from "matrix-js-sdk/src/logger"; -import type CreateKeyBackupDialog from "../../../async-components/views/dialogs/security/CreateKeyBackupDialog"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { _t } from "../../../languageHandler"; import Modal from "../../../Modal"; @@ -170,10 +169,8 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> { } private startNewBackup = (): void => { - Modal.createDialogAsync( - import("../../../async-components/views/dialogs/security/CreateKeyBackupDialog") as unknown as Promise< - typeof CreateKeyBackupDialog - >, + Modal.createDialog( + lazy(() => import("../../../async-components/views/dialogs/security/CreateKeyBackupDialog")), { onFinished: () => { this.loadBackupStatus(); diff --git a/test/unit-tests/SecurityManager-test.ts b/test/unit-tests/SecurityManager-test.ts index 5ba60ac638..63143d4644 100644 --- a/test/unit-tests/SecurityManager-test.ts +++ b/test/unit-tests/SecurityManager-test.ts @@ -11,6 +11,13 @@ import { CryptoApi } from "matrix-js-sdk/src/crypto-api"; import { accessSecretStorage } from "../../src/SecurityManager"; import { filterConsole, stubClient } from "../test-utils"; +import Modal from "../../src/Modal.tsx"; + +jest.mock("react", () => { + const React = jest.requireActual("react"); + React.lazy = (children: any) => children(); // stub out lazy for dialog test + return React; +}); describe("SecurityManager", () => { describe("accessSecretStorage", () => { @@ -50,5 +57,21 @@ describe("SecurityManager", () => { }).rejects.toThrow("End-to-end encryption is disabled - unable to access secret storage"); }); }); + + it("should show CreateSecretStorageDialog if forceReset=true", async () => { + jest.mock("../../src/async-components/views/dialogs/security/CreateSecretStorageDialog", () => ({ + __test: true, + __esModule: true, + default: () => jest.fn(), + })); + const spy = jest.spyOn(Modal, "createDialog"); + stubClient(); + + const func = jest.fn(); + accessSecretStorage(func, true); + + expect(spy).toHaveBeenCalledTimes(1); + await expect(spy.mock.lastCall![0]).resolves.toEqual(expect.objectContaining({ __test: true })); + }); }); }); diff --git a/test/unit-tests/async-components/dialogs/security/RecoveryMethodRemovedDialog-test.tsx b/test/unit-tests/async-components/dialogs/security/RecoveryMethodRemovedDialog-test.tsx new file mode 100644 index 0000000000..e351524427 --- /dev/null +++ b/test/unit-tests/async-components/dialogs/security/RecoveryMethodRemovedDialog-test.tsx @@ -0,0 +1,33 @@ +/* + * 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, screen, fireEvent, waitFor } from "jest-matrix-react"; + +import RecoveryMethodRemovedDialog from "../../../../../src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog"; +import Modal from "../../../../../src/Modal.tsx"; + +describe("", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("should open CreateKeyBackupDialog on primary action click", async () => { + const onFinished = jest.fn(); + const spy = jest.spyOn(Modal, "createDialog"); + jest.mock("../../../../../src/async-components/views/dialogs/security/CreateKeyBackupDialog", () => ({ + __test: true, + __esModule: true, + default: () => mocked dialog, + })); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Set up Secure Messages" })); + await waitFor(() => expect(spy).toHaveBeenCalledTimes(1)); + expect((spy.mock.lastCall![0] as any)._payload._result).toEqual(expect.objectContaining({ __test: true })); + }); +}); diff --git a/test/unit-tests/components/structures/MatrixChat-test.tsx b/test/unit-tests/components/structures/MatrixChat-test.tsx index 4b396b66a9..16106ee0d2 100644 --- a/test/unit-tests/components/structures/MatrixChat-test.tsx +++ b/test/unit-tests/components/structures/MatrixChat-test.tsx @@ -149,6 +149,7 @@ describe("", () => { isRoomEncrypted: jest.fn(), logout: jest.fn(), getDeviceId: jest.fn(), + getKeyBackupVersion: jest.fn().mockResolvedValue(null), }); let mockClient: Mocked; const serverConfig = { @@ -1515,7 +1516,7 @@ describe("", () => { describe("when key backup failed", () => { it("should show the new recovery method dialog", async () => { - const spy = jest.spyOn(Modal, "createDialogAsync"); + const spy = jest.spyOn(Modal, "createDialog"); jest.mock("../../../../src/async-components/views/dialogs/security/NewRecoveryMethodDialog", () => ({ __test: true, __esModule: true, @@ -1530,7 +1531,25 @@ describe("", () => { await flushPromises(); mockClient.emit(CryptoEvent.KeyBackupFailed, "error code"); await waitFor(() => expect(spy).toHaveBeenCalledTimes(1)); - expect(await spy.mock.lastCall![0]).toEqual(expect.objectContaining({ __test: true })); + expect((spy.mock.lastCall![0] as any)._payload._result).toEqual(expect.objectContaining({ __test: true })); + }); + + it("should show the recovery method removed dialog", async () => { + const spy = jest.spyOn(Modal, "createDialog"); + jest.mock("../../../../src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog", () => ({ + __test: true, + __esModule: true, + default: () => mocked dialog, + })); + + getComponent({}); + defaultDispatcher.dispatch({ + action: "will_start_client", + }); + await flushPromises(); + mockClient.emit(CryptoEvent.KeyBackupFailed, "error code"); + await waitFor(() => expect(spy).toHaveBeenCalledTimes(1)); + expect((spy.mock.lastCall![0] as any)._payload._result).toEqual(expect.objectContaining({ __test: true })); }); }); }); diff --git a/test/unit-tests/components/views/dialogs/LogoutDialog-test.tsx b/test/unit-tests/components/views/dialogs/LogoutDialog-test.tsx index 03db42fba7..46fe519b47 100644 --- a/test/unit-tests/components/views/dialogs/LogoutDialog-test.tsx +++ b/test/unit-tests/components/views/dialogs/LogoutDialog-test.tsx @@ -10,7 +10,7 @@ import React from "react"; import { mocked, MockedObject } from "jest-mock"; import { MatrixClient } from "matrix-js-sdk/src/matrix"; import { CryptoApi, KeyBackupInfo } from "matrix-js-sdk/src/crypto-api"; -import { render, RenderResult } from "jest-matrix-react"; +import { fireEvent, render, RenderResult, screen } from "jest-matrix-react"; import { filterConsole, getMockClientWithEventEmitter, mockClientMethodsCrypto } from "../../../../test-utils"; import LogoutDialog from "../../../../../src/components/views/dialogs/LogoutDialog"; @@ -61,6 +61,9 @@ describe("LogoutDialog", () => { const rendered = renderComponent(); await rendered.findByText("Start using Key Backup"); expect(rendered.container).toMatchSnapshot(); + + fireEvent.click(await screen.findByRole("button", { name: "Manually export keys" })); + await expect(screen.findByRole("heading", { name: "Export room keys" })).resolves.toBeInTheDocument(); }); describe("when there is an error fetching backups", () => { diff --git a/test/unit-tests/components/views/settings/AddRemoveThreepids-test.tsx b/test/unit-tests/components/views/settings/AddRemoveThreepids-test.tsx index 3b46d74435..7fa6619a99 100644 --- a/test/unit-tests/components/views/settings/AddRemoveThreepids-test.tsx +++ b/test/unit-tests/components/views/settings/AddRemoveThreepids-test.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import { render, screen, waitFor } from "jest-matrix-react"; -import { MatrixClient, ThreepidMedium } from "matrix-js-sdk/src/matrix"; +import { MatrixClient, MatrixError, ThreepidMedium } from "matrix-js-sdk/src/matrix"; import React from "react"; import userEvent from "@testing-library/user-event"; import { mocked } from "jest-mock"; @@ -16,6 +16,7 @@ import { AddRemoveThreepids } from "../../../../../src/components/views/settings import { clearAllModals, stubClient } from "../../../../test-utils"; import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; import Modal from "../../../../../src/Modal"; +import InteractiveAuthDialog from "../../../../../src/components/views/dialogs/InteractiveAuthDialog.tsx"; const MOCK_IDENTITY_ACCESS_TOKEN = "mock_identity_access_token"; const mockGetAccessToken = jest.fn().mockResolvedValue(MOCK_IDENTITY_ACCESS_TOKEN); @@ -222,13 +223,13 @@ describe("AddRemoveThreepids", () => { const continueButton = await screen.findByRole("button", { name: /Continue/ }); - await expect(continueButton).toHaveAttribute("aria-disabled", "true"); + expect(continueButton).toHaveAttribute("aria-disabled", "true"); await expect( - await screen.findByText( + screen.findByText( `A text message has been sent to +${PHONE1.address}. Please enter the verification code it contains.`, ), - ).toBeInTheDocument(); + ).resolves.toBeInTheDocument(); expect(client.requestAdd3pidMsisdnToken).toHaveBeenCalledWith( "GB", @@ -481,4 +482,118 @@ describe("AddRemoveThreepids", () => { expect(client.unbindThreePid).toHaveBeenCalledWith(ThreepidMedium.Phone, PHONE1.address); expect(onChangeFn).toHaveBeenCalled(); }); + + it("should show UIA dialog when necessary for adding email", async () => { + const onChangeFn = jest.fn(); + const createDialogFn = jest.spyOn(Modal, "createDialog"); + mocked(client.requestAdd3pidEmailToken).mockResolvedValue({ sid: "1" }); + + render( + , + { + wrapper: clientProviderWrapper, + }, + ); + + const input = screen.getByRole("textbox", { name: "Email Address" }); + await userEvent.type(input, EMAIL1.address); + const addButton = screen.getByRole("button", { name: "Add" }); + await userEvent.click(addButton); + + const continueButton = screen.getByRole("button", { name: "Continue" }); + + expect(continueButton).toBeEnabled(); + + mocked(client).addThreePidOnly.mockRejectedValueOnce( + new MatrixError({ errcode: "M_UNAUTHORIZED", flows: [{ stages: [] }] }, 401), + ); + + await userEvent.click(continueButton); + + expect(createDialogFn).toHaveBeenCalledWith( + InteractiveAuthDialog, + expect.objectContaining({ + title: "Add Email Address", + makeRequest: expect.any(Function), + }), + ); + }); + + it("should show UIA dialog when necessary for adding msisdn", async () => { + const onChangeFn = jest.fn(); + const createDialogFn = jest.spyOn(Modal, "createDialog"); + mocked(client.requestAdd3pidMsisdnToken).mockResolvedValue({ + sid: "1", + msisdn: PHONE1.address, + intl_fmt: PHONE1.address, + success: true, + submit_url: "https://some-url", + }); + + render( + , + { + wrapper: clientProviderWrapper, + }, + ); + + const countryDropdown = screen.getByRole("button", { name: /Country Dropdown/ }); + await userEvent.click(countryDropdown); + const gbOption = screen.getByRole("option", { name: "🇬🇧 United Kingdom (+44)" }); + await userEvent.click(gbOption); + + const input = screen.getByRole("textbox", { name: "Phone Number" }); + await userEvent.type(input, PHONE1_LOCALNUM); + + const addButton = screen.getByRole("button", { name: "Add" }); + await userEvent.click(addButton); + + const continueButton = screen.getByRole("button", { name: "Continue" }); + + expect(continueButton).toHaveAttribute("aria-disabled", "true"); + + await expect( + screen.findByText( + `A text message has been sent to +${PHONE1.address}. Please enter the verification code it contains.`, + ), + ).resolves.toBeInTheDocument(); + + expect(client.requestAdd3pidMsisdnToken).toHaveBeenCalledWith( + "GB", + PHONE1_LOCALNUM, + client.generateClientSecret(), + 1, + ); + + const verificationInput = screen.getByRole("textbox", { name: "Verification code" }); + await userEvent.type(verificationInput, "123456"); + + expect(continueButton).not.toHaveAttribute("aria-disabled", "true"); + + mocked(client).addThreePidOnly.mockRejectedValueOnce( + new MatrixError({ errcode: "M_UNAUTHORIZED", flows: [{ stages: [] }] }, 401), + ); + + await userEvent.click(continueButton); + + expect(createDialogFn).toHaveBeenCalledWith( + InteractiveAuthDialog, + expect.objectContaining({ + title: "Add Phone Number", + makeRequest: expect.any(Function), + }), + ); + }); }); diff --git a/test/unit-tests/components/views/settings/CryptographyPanel-test.tsx b/test/unit-tests/components/views/settings/CryptographyPanel-test.tsx index 0b699c1383..03b469fe76 100644 --- a/test/unit-tests/components/views/settings/CryptographyPanel-test.tsx +++ b/test/unit-tests/components/views/settings/CryptographyPanel-test.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { render, waitFor } from "jest-matrix-react"; +import { render, waitFor, screen, fireEvent } from "jest-matrix-react"; import { MatrixClient } from "matrix-js-sdk/src/matrix"; import { mocked } from "jest-mock"; @@ -64,4 +64,34 @@ describe("CryptographyPanel", () => { // Then "not supported key await waitFor(() => expect(codes[1].innerHTML).toEqual("<not supported>")); }); + + it("should open the export e2e keys dialog on click", async () => { + const sessionId = "ABCDEFGHIJ"; + const sessionKey = "AbCDeFghIJK7L/m4nOPqRSTUVW4xyzaBCDef6gHIJkl"; + + TestUtils.stubClient(); + const client: MatrixClient = MatrixClientPeg.safeGet(); + client.deviceId = sessionId; + + mocked(client.getCrypto()!.getOwnDeviceKeys).mockResolvedValue({ ed25519: sessionKey, curve25519: "1234" }); + + render(, withClientContextRenderOptions(client)); + fireEvent.click(await screen.findByRole("button", { name: "Export E2E room keys" })); + await expect(screen.findByRole("heading", { name: "Export room keys" })).resolves.toBeInTheDocument(); + }); + + it("should open the import e2e keys dialog on click", async () => { + const sessionId = "ABCDEFGHIJ"; + const sessionKey = "AbCDeFghIJK7L/m4nOPqRSTUVW4xyzaBCDef6gHIJkl"; + + TestUtils.stubClient(); + const client: MatrixClient = MatrixClientPeg.safeGet(); + client.deviceId = sessionId; + + mocked(client.getCrypto()!.getOwnDeviceKeys).mockResolvedValue({ ed25519: sessionKey, curve25519: "1234" }); + + render(, withClientContextRenderOptions(client)); + fireEvent.click(await screen.findByRole("button", { name: "Import E2E room keys" })); + await expect(screen.findByRole("heading", { name: "Import room keys" })).resolves.toBeInTheDocument(); + }); });