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>
This commit is contained in:
Michael Telatynski 2024-11-12 21:19:11 +00:00 committed by GitHub
parent 9b5d0866e0
commit 27a43e860a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 306 additions and 158 deletions

View file

@ -10,13 +10,13 @@ Please see LICENSE files in the repository root for full details.
import { import {
IAddThreePidOnlyBody, IAddThreePidOnlyBody,
IAuthData,
IRequestMsisdnTokenResponse, IRequestMsisdnTokenResponse,
IRequestTokenResponse, IRequestTokenResponse,
MatrixClient, MatrixClient,
MatrixError, MatrixError,
HTTPError, HTTPError,
IThreepid, IThreepid,
UIAResponse,
} from "matrix-js-sdk/src/matrix"; } from "matrix-js-sdk/src/matrix";
import Modal from "./Modal"; import Modal from "./Modal";
@ -179,7 +179,9 @@ export default class AddThreepid {
* with a "message" property which contains a human-readable message detailing why * with a "message" property which contains a human-readable message detailing why
* the request failed. * the request failed.
*/ */
public async checkEmailLinkClicked(): Promise<[success?: boolean, result?: IAuthData | Error | null]> { public async checkEmailLinkClicked(): Promise<
[success?: boolean, result?: UIAResponse<IAddThreePidOnlyBody> | Error | null]
> {
try { try {
if (this.bind) { if (this.bind) {
const authClient = new IdentityAuthClient(); const authClient = new IdentityAuthClient();
@ -220,7 +222,7 @@ export default class AddThreepid {
continueKind: "primary", continueKind: "primary",
}, },
}; };
const { finished } = Modal.createDialog(InteractiveAuthDialog<{}>, { const { finished } = Modal.createDialog(InteractiveAuthDialog<IAddThreePidOnlyBody>, {
title: _t("settings|general|add_email_dialog_title"), title: _t("settings|general|add_email_dialog_title"),
matrixClient: this.matrixClient, matrixClient: this.matrixClient,
authData: err.data, authData: err.data,
@ -263,7 +265,9 @@ export default class AddThreepid {
* with a "message" property which contains a human-readable message detailing why * with a "message" property which contains a human-readable message detailing why
* the request failed. * 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<IAddThreePidOnlyBody> | Error | null]> {
const authClient = new IdentityAuthClient(); const authClient = new IdentityAuthClient();
if (this.submitUrl) { if (this.submitUrl) {
@ -319,7 +323,7 @@ export default class AddThreepid {
continueKind: "primary", continueKind: "primary",
}, },
}; };
const { finished } = Modal.createDialog(InteractiveAuthDialog<{}>, { const { finished } = Modal.createDialog(InteractiveAuthDialog<IAddThreePidOnlyBody>, {
title: _t("settings|general|add_msisdn_dialog_title"), title: _t("settings|general|add_msisdn_dialog_title"),
matrixClient: this.matrixClient, matrixClient: this.matrixClient,
authData: err.data, authData: err.data,

View file

@ -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. Please see LICENSE files in the repository root for full details.
*/ */
import React, { ComponentType, PropsWithChildren } from "react"; import React, { ReactNode, Suspense } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { _t } from "./languageHandler"; import { _t } from "./languageHandler";
import BaseDialog from "./components/views/dialogs/BaseDialog"; import BaseDialog from "./components/views/dialogs/BaseDialog";
import DialogButtons from "./components/views/elements/DialogButtons"; import DialogButtons from "./components/views/elements/DialogButtons";
import Spinner from "./components/views/elements/Spinner"; import Spinner from "./components/views/elements/Spinner";
type AsyncImport<T> = { default: T };
interface IProps { interface IProps {
// A promise which resolves with the real component
prom: Promise<ComponentType<any> | AsyncImport<ComponentType<any>>>;
onFinished(): void; onFinished(): void;
children: ReactNode;
} }
interface IState { interface IState {
component?: ComponentType<PropsWithChildren<any>>;
error?: Error; error?: Error;
} }
@ -32,56 +27,26 @@ interface IState {
* spinner until the real component loads. * spinner until the real component loads.
*/ */
export default class AsyncWrapper extends React.Component<IProps, IState> { export default class AsyncWrapper extends React.Component<IProps, IState> {
private unmounted = false; public static getDerivedStateFromError(error: Error): IState {
return { error };
}
public state: IState = {}; 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<ComponentType>).default
? (result as AsyncImport<ComponentType>).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 { public render(): React.ReactNode {
if (this.state.component) { if (this.state.error) {
const Component = this.state.component;
return <Component {...this.props} />;
} else if (this.state.error) {
return ( return (
<BaseDialog onFinished={this.props.onFinished} title={_t("common|error")}> <BaseDialog onFinished={this.props.onFinished} title={_t("common|error")}>
{_t("failed_load_async_component")} {_t("failed_load_async_component")}
<DialogButtons <DialogButtons
primaryButton={_t("action|dismiss")} primaryButton={_t("action|dismiss")}
onPrimaryButtonClick={this.onWrapperCancelClick} onPrimaryButtonClick={this.props.onFinished}
hasCancel={false} hasCancel={false}
/> />
</BaseDialog> </BaseDialog>
); );
} else {
// show a spinner until the component is loaded.
return <Spinner />;
} }
return <Suspense fallback={<Spinner />}>{this.props.children}</Suspense>;
} }
} }

View file

@ -136,32 +136,6 @@ export class ModalManager extends TypedEventEmitter<ModalManagerEvent, HandlerMa
return !!this.priorityModal || !!this.staticModal || this.modals.length > 0; return !!this.priorityModal || !!this.staticModal || this.modals.length > 0;
} }
public createDialog<C extends ComponentType>(
Element: C,
props?: ComponentProps<C>,
className?: string,
isPriorityModal = false,
isStaticModal = false,
options: IOptions<C> = {},
): IHandle<C> {
return this.createDialogAsync<C>(
Promise.resolve(Element),
props,
className,
isPriorityModal,
isStaticModal,
options,
);
}
public appendDialog<C extends ComponentType>(
Element: C,
props?: ComponentProps<C>,
className?: string,
): IHandle<C> {
return this.appendDialogAsync<C>(Promise.resolve(Element), props, className);
}
/** /**
* DEPRECATED. * DEPRECATED.
* This is used only for tests. They should be using forceCloseAllModals but that * This is used only for tests. They should be using forceCloseAllModals but that
@ -196,8 +170,11 @@ export class ModalManager extends TypedEventEmitter<ModalManagerEvent, HandlerMa
this.reRender(); this.reRender();
} }
/**
* @typeParam C - the component type
*/
private buildModal<C extends ComponentType>( private buildModal<C extends ComponentType>(
prom: Promise<C>, Component: C,
props?: ComponentProps<C>, props?: ComponentProps<C>,
className?: string, className?: string,
options?: IOptions<C>, options?: IOptions<C>,
@ -222,9 +199,12 @@ export class ModalManager extends TypedEventEmitter<ModalManagerEvent, HandlerMa
// otherwise we'll get confused. // otherwise we'll get confused.
const modalCount = this.counter++; const modalCount = this.counter++;
// FIXME: If a dialog uses getDefaultProps it clobbers the onFinished // Typescript doesn't like us passing props as any here, but we know that they are well typed due to the rigorous generics.
// property set here so you can't close the dialog from a button click! modal.elem = (
modal.elem = <AsyncWrapper key={modalCount} prom={prom} {...props} onFinished={closeDialog} />; <AsyncWrapper key={modalCount} onFinished={closeDialog}>
<Component {...(props as any)} onFinished={closeDialog} />
</AsyncWrapper>
);
modal.close = closeDialog; modal.close = closeDialog;
return { modal, closeDialog, onFinishedProm }; return { modal, closeDialog, onFinishedProm };
@ -291,29 +271,30 @@ export class ModalManager extends TypedEventEmitter<ModalManagerEvent, HandlerMa
* require(['<module>'], cb); * require(['<module>'], cb);
* } * }
* *
* @param {Promise} prom a promise which resolves with a React component * @param component The component to render as a dialog. This component must accept an `onFinished` prop function as
* which will be displayed as the modal view. * 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 * @param props properties to pass to the displayed component. (We will also pass an 'onFinished' property.)
* 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. * of other modals that are currently in the stack.
* Also, when closed, all modals will be removed * Also, when closed, all modals will be removed
* from the stack. * 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 * modals in the stack. When closed, all modals will
* also be removed from the stack. This is not compatible * also be removed from the stack. This is not compatible
* with being a priority modal. Only one modal can be * with being a priority modal. Only one modal can be
* static at a time. * static at a time.
* @param {Object} options? extra options for the dialog * @param options? extra options for the dialog
* @param {onBeforeClose} options.onBeforeClose a callback to decide whether to close the dialog * @param 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 * @returns Object with 'close' parameter being a function that will close the dialog
*/ */
public createDialogAsync<C extends ComponentType>( public createDialog<C extends ComponentType>(
prom: Promise<C>, component: C,
props?: ComponentProps<C>, props?: ComponentProps<C>,
className?: string, className?: string,
isPriorityModal = false, isPriorityModal = false,
@ -321,7 +302,7 @@ export class ModalManager extends TypedEventEmitter<ModalManagerEvent, HandlerMa
options: IOptions<C> = {}, options: IOptions<C> = {},
): IHandle<C> { ): IHandle<C> {
const beforeModal = this.getCurrentModal(); const beforeModal = this.getCurrentModal();
const { modal, closeDialog, onFinishedProm } = this.buildModal<C>(prom, props, className, options); const { modal, closeDialog, onFinishedProm } = this.buildModal<C>(component, props, className, options);
if (isPriorityModal) { if (isPriorityModal) {
// XXX: This is destructive // XXX: This is destructive
this.priorityModal = modal; this.priorityModal = modal;
@ -341,13 +322,13 @@ export class ModalManager extends TypedEventEmitter<ModalManagerEvent, HandlerMa
}; };
} }
private appendDialogAsync<C extends ComponentType>( public appendDialog<C extends ComponentType>(
prom: Promise<C>, component: C,
props?: ComponentProps<C>, props?: ComponentProps<C>,
className?: string, className?: string,
): IHandle<C> { ): IHandle<C> {
const beforeModal = this.getCurrentModal(); const beforeModal = this.getCurrentModal();
const { modal, closeDialog, onFinishedProm } = this.buildModal<C>(prom, props, className, {}); const { modal, closeDialog, onFinishedProm } = this.buildModal<C>(component, props, className, {});
this.modals.push(modal); this.modals.push(modal);

View file

@ -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. 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 { ICryptoCallbacks, SecretStorage } from "matrix-js-sdk/src/matrix";
import { deriveRecoveryKeyFromPassphrase, decodeRecoveryKey } from "matrix-js-sdk/src/crypto-api"; import { deriveRecoveryKeyFromPassphrase, decodeRecoveryKey } from "matrix-js-sdk/src/crypto-api";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import type CreateSecretStorageDialog from "./async-components/views/dialogs/security/CreateSecretStorageDialog";
import Modal from "./Modal"; import Modal from "./Modal";
import { MatrixClientPeg } from "./MatrixClientPeg"; import { MatrixClientPeg } from "./MatrixClientPeg";
import { _t } from "./languageHandler"; import { _t } from "./languageHandler";
@ -232,10 +232,8 @@ async function doAccessSecretStorage(func: () => Promise<void>, forceReset: bool
if (createNew) { if (createNew) {
// This dialog calls bootstrap itself after guiding the user through // This dialog calls bootstrap itself after guiding the user through
// passphrase creation. // passphrase creation.
const { finished } = Modal.createDialogAsync( const { finished } = Modal.createDialog(
import("./async-components/views/dialogs/security/CreateSecretStorageDialog") as unknown as Promise< lazy(() => import("./async-components/views/dialogs/security/CreateSecretStorageDialog")),
typeof CreateSecretStorageDialog
>,
{ {
forceReset, forceReset,
}, },

View file

@ -28,7 +28,7 @@ interface NewRecoveryMethodDialogProps {
onFinished(): void; 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. * Dialog to inform the user that a new recovery method has been detected.

View file

@ -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. 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 dis from "../../../../dispatcher/dispatcher";
import { _t } from "../../../../languageHandler"; import { _t } from "../../../../languageHandler";
import Modal, { ComponentType } from "../../../../Modal"; import Modal from "../../../../Modal";
import { Action } from "../../../../dispatcher/actions"; import { Action } from "../../../../dispatcher/actions";
import BaseDialog from "../../../../components/views/dialogs/BaseDialog"; import BaseDialog from "../../../../components/views/dialogs/BaseDialog";
import DialogButtons from "../../../../components/views/elements/DialogButtons"; import DialogButtons from "../../../../components/views/elements/DialogButtons";
@ -28,8 +28,8 @@ export default class RecoveryMethodRemovedDialog extends React.PureComponent<IPr
private onSetupClick = (): void => { private onSetupClick = (): void => {
this.props.onFinished(); this.props.onFinished();
Modal.createDialogAsync( Modal.createDialog(
import("./CreateKeyBackupDialog") as unknown as Promise<ComponentType>, lazy(() => import("./CreateKeyBackupDialog")),
undefined, undefined,
undefined, undefined,
/* priority = */ false, /* priority = */ false,

View file

@ -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. Please see LICENSE files in the repository root for full details.
*/ */
import React, { createRef } from "react"; import React, { createRef, lazy } from "react";
import { import {
ClientEvent, ClientEvent,
createClient, createClient,
@ -28,8 +28,6 @@ import { TooltipProvider } from "@vector-im/compound-web";
// what-input helps improve keyboard accessibility // what-input helps improve keyboard accessibility
import "what-input"; 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 PosthogTrackers from "../../PosthogTrackers";
import { DecryptionFailureTracker } from "../../DecryptionFailureTracker"; import { DecryptionFailureTracker } from "../../DecryptionFailureTracker";
import { IMatrixClientCreds, MatrixClientPeg } from "../../MatrixClientPeg"; import { IMatrixClientCreds, MatrixClientPeg } from "../../MatrixClientPeg";
@ -1649,16 +1647,12 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
} }
if (haveNewVersion) { if (haveNewVersion) {
Modal.createDialogAsync( Modal.createDialog(
import( lazy(() => import("../../async-components/views/dialogs/security/NewRecoveryMethodDialog")),
"../../async-components/views/dialogs/security/NewRecoveryMethodDialog"
) as unknown as Promise<typeof NewRecoveryMethodDialog>,
); );
} else { } else {
Modal.createDialogAsync( Modal.createDialog(
import( lazy(() => import("../../async-components/views/dialogs/security/RecoveryMethodRemovedDialog")),
"../../async-components/views/dialogs/security/RecoveryMethodRemovedDialog"
) as unknown as Promise<typeof RecoveryMethodRemovedDialog>,
); );
} }
}); });

View file

@ -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. 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 { logger } from "matrix-js-sdk/src/logger";
import { MatrixClient } from "matrix-js-sdk/src/matrix"; 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 Modal from "../../../Modal";
import dis from "../../../dispatcher/dispatcher"; import dis from "../../../dispatcher/dispatcher";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
@ -116,10 +114,8 @@ export default class LogoutDialog extends React.Component<IProps, IState> {
} }
private onExportE2eKeysClicked = (): void => { private onExportE2eKeysClicked = (): void => {
Modal.createDialogAsync( Modal.createDialog(
import("../../../async-components/views/dialogs/security/ExportE2eKeysDialog") as unknown as Promise< lazy(() => import("../../../async-components/views/dialogs/security/ExportE2eKeysDialog")),
typeof ExportE2eKeysDialog
>,
{ {
matrixClient: MatrixClientPeg.safeGet(), matrixClient: MatrixClientPeg.safeGet(),
}, },
@ -147,10 +143,8 @@ export default class LogoutDialog extends React.Component<IProps, IState> {
/* static = */ true, /* static = */ true,
); );
} else { } else {
Modal.createDialogAsync( Modal.createDialog(
import("../../../async-components/views/dialogs/security/CreateKeyBackupDialog") as unknown as Promise< lazy(() => import("../../../async-components/views/dialogs/security/CreateKeyBackupDialog")),
typeof CreateKeyBackupDialog
>,
undefined, undefined,
undefined, undefined,
/* priority = */ false, /* priority = */ false,

View file

@ -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. 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 { 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 { _t } from "../../../languageHandler";
import Modal from "../../../Modal"; import Modal from "../../../Modal";
import AccessibleButton from "../elements/AccessibleButton"; import AccessibleButton from "../elements/AccessibleButton";
@ -129,19 +127,15 @@ export default class CryptographyPanel extends React.Component<IProps, IState> {
} }
private onExportE2eKeysClicked = (): void => { private onExportE2eKeysClicked = (): void => {
Modal.createDialogAsync( Modal.createDialog(
import("../../../async-components/views/dialogs/security/ExportE2eKeysDialog") as unknown as Promise< lazy(() => import("../../../async-components/views/dialogs/security/ExportE2eKeysDialog")),
typeof ExportE2eKeysDialog
>,
{ matrixClient: this.context }, { matrixClient: this.context },
); );
}; };
private onImportE2eKeysClicked = (): void => { private onImportE2eKeysClicked = (): void => {
Modal.createDialogAsync( Modal.createDialog(
import("../../../async-components/views/dialogs/security/ImportE2eKeysDialog") as unknown as Promise< lazy(() => import("../../../async-components/views/dialogs/security/ImportE2eKeysDialog")),
typeof ImportE2eKeysDialog
>,
{ matrixClient: this.context }, { matrixClient: this.context },
); );
}; };

View file

@ -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. 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 { _t } from "../../../languageHandler";
import SdkConfig from "../../../SdkConfig"; import SdkConfig from "../../../SdkConfig";
@ -94,14 +94,12 @@ export default class EventIndexPanel extends React.Component<{}, IState> {
} }
private onManage = async (): Promise<void> => { private onManage = async (): Promise<void> => {
Modal.createDialogAsync( Modal.createDialog(
// @ts-ignore: TS doesn't seem to like the type of this now that it lazy(() => import("../../../async-components/views/dialogs/eventindex/ManageEventIndexDialog")),
// has also been converted to TS as well, but I can't figure out why...
import("../../../async-components/views/dialogs/eventindex/ManageEventIndexDialog"),
{ {
onFinished: () => {}, onFinished: () => {},
}, },
null, undefined,
/* priority = */ false, /* priority = */ false,
/* static = */ true, /* static = */ true,
); );

View file

@ -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. 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 { CryptoEvent, BackupTrustInfo, KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import type CreateKeyBackupDialog from "../../../async-components/views/dialogs/security/CreateKeyBackupDialog";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import Modal from "../../../Modal"; import Modal from "../../../Modal";
@ -170,10 +169,8 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> {
} }
private startNewBackup = (): void => { private startNewBackup = (): void => {
Modal.createDialogAsync( Modal.createDialog(
import("../../../async-components/views/dialogs/security/CreateKeyBackupDialog") as unknown as Promise< lazy(() => import("../../../async-components/views/dialogs/security/CreateKeyBackupDialog")),
typeof CreateKeyBackupDialog
>,
{ {
onFinished: () => { onFinished: () => {
this.loadBackupStatus(); this.loadBackupStatus();

View file

@ -11,6 +11,13 @@ import { CryptoApi } from "matrix-js-sdk/src/crypto-api";
import { accessSecretStorage } from "../../src/SecurityManager"; import { accessSecretStorage } from "../../src/SecurityManager";
import { filterConsole, stubClient } from "../test-utils"; 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("SecurityManager", () => {
describe("accessSecretStorage", () => { describe("accessSecretStorage", () => {
@ -50,5 +57,21 @@ describe("SecurityManager", () => {
}).rejects.toThrow("End-to-end encryption is disabled - unable to access secret storage"); }).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 }));
});
}); });
}); });

View file

@ -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("<RecoveryMethodRemovedDialog />", () => {
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: () => <span>mocked dialog</span>,
}));
render(<RecoveryMethodRemovedDialog onFinished={onFinished} />);
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 }));
});
});

View file

@ -149,6 +149,7 @@ describe("<MatrixChat />", () => {
isRoomEncrypted: jest.fn(), isRoomEncrypted: jest.fn(),
logout: jest.fn(), logout: jest.fn(),
getDeviceId: jest.fn(), getDeviceId: jest.fn(),
getKeyBackupVersion: jest.fn().mockResolvedValue(null),
}); });
let mockClient: Mocked<MatrixClient>; let mockClient: Mocked<MatrixClient>;
const serverConfig = { const serverConfig = {
@ -1515,7 +1516,7 @@ describe("<MatrixChat />", () => {
describe("when key backup failed", () => { describe("when key backup failed", () => {
it("should show the new recovery method dialog", async () => { 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", () => ({ jest.mock("../../../../src/async-components/views/dialogs/security/NewRecoveryMethodDialog", () => ({
__test: true, __test: true,
__esModule: true, __esModule: true,
@ -1530,7 +1531,25 @@ describe("<MatrixChat />", () => {
await flushPromises(); await flushPromises();
mockClient.emit(CryptoEvent.KeyBackupFailed, "error code"); mockClient.emit(CryptoEvent.KeyBackupFailed, "error code");
await waitFor(() => expect(spy).toHaveBeenCalledTimes(1)); 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: () => <span>mocked dialog</span>,
}));
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 }));
}); });
}); });
}); });

View file

@ -10,7 +10,7 @@ import React from "react";
import { mocked, MockedObject } from "jest-mock"; import { mocked, MockedObject } from "jest-mock";
import { MatrixClient } from "matrix-js-sdk/src/matrix"; import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { CryptoApi, KeyBackupInfo } from "matrix-js-sdk/src/crypto-api"; 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 { filterConsole, getMockClientWithEventEmitter, mockClientMethodsCrypto } from "../../../../test-utils";
import LogoutDialog from "../../../../../src/components/views/dialogs/LogoutDialog"; import LogoutDialog from "../../../../../src/components/views/dialogs/LogoutDialog";
@ -61,6 +61,9 @@ describe("LogoutDialog", () => {
const rendered = renderComponent(); const rendered = renderComponent();
await rendered.findByText("Start using Key Backup"); await rendered.findByText("Start using Key Backup");
expect(rendered.container).toMatchSnapshot(); 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", () => { describe("when there is an error fetching backups", () => {

View file

@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
*/ */
import { render, screen, waitFor } from "jest-matrix-react"; 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 React from "react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { mocked } from "jest-mock"; import { mocked } from "jest-mock";
@ -16,6 +16,7 @@ import { AddRemoveThreepids } from "../../../../../src/components/views/settings
import { clearAllModals, stubClient } from "../../../../test-utils"; import { clearAllModals, stubClient } from "../../../../test-utils";
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
import Modal from "../../../../../src/Modal"; import Modal from "../../../../../src/Modal";
import InteractiveAuthDialog from "../../../../../src/components/views/dialogs/InteractiveAuthDialog.tsx";
const MOCK_IDENTITY_ACCESS_TOKEN = "mock_identity_access_token"; const MOCK_IDENTITY_ACCESS_TOKEN = "mock_identity_access_token";
const mockGetAccessToken = jest.fn().mockResolvedValue(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/ }); const continueButton = await screen.findByRole("button", { name: /Continue/ });
await expect(continueButton).toHaveAttribute("aria-disabled", "true"); expect(continueButton).toHaveAttribute("aria-disabled", "true");
await expect( await expect(
await screen.findByText( screen.findByText(
`A text message has been sent to +${PHONE1.address}. Please enter the verification code it contains.`, `A text message has been sent to +${PHONE1.address}. Please enter the verification code it contains.`,
), ),
).toBeInTheDocument(); ).resolves.toBeInTheDocument();
expect(client.requestAdd3pidMsisdnToken).toHaveBeenCalledWith( expect(client.requestAdd3pidMsisdnToken).toHaveBeenCalledWith(
"GB", "GB",
@ -481,4 +482,118 @@ describe("AddRemoveThreepids", () => {
expect(client.unbindThreePid).toHaveBeenCalledWith(ThreepidMedium.Phone, PHONE1.address); expect(client.unbindThreePid).toHaveBeenCalledWith(ThreepidMedium.Phone, PHONE1.address);
expect(onChangeFn).toHaveBeenCalled(); 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(
<AddRemoveThreepids
mode="hs"
medium={ThreepidMedium.Email}
threepids={[]}
isLoading={false}
onChange={onChangeFn}
/>,
{
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(
<AddRemoveThreepids
mode="hs"
medium={ThreepidMedium.Phone}
threepids={[]}
isLoading={false}
onChange={onChangeFn}
/>,
{
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),
}),
);
});
}); });

View file

@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
*/ */
import React from "react"; 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 { MatrixClient } from "matrix-js-sdk/src/matrix";
import { mocked } from "jest-mock"; import { mocked } from "jest-mock";
@ -64,4 +64,34 @@ describe("CryptographyPanel", () => {
// Then "not supported key // Then "not supported key
await waitFor(() => expect(codes[1].innerHTML).toEqual("<strong>&lt;not supported&gt;</strong>")); await waitFor(() => expect(codes[1].innerHTML).toEqual("<strong>&lt;not supported&gt;</strong>"));
}); });
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(<CryptographyPanel />, 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(<CryptographyPanel />, withClientContextRenderOptions(client));
fireEvent.click(await screen.findByRole("button", { name: "Import E2E room keys" }));
await expect(screen.findByRole("heading", { name: "Import room keys" })).resolves.toBeInTheDocument();
});
}); });