Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit dcf7643

Browse files
authored
Fix closing all modals (#12728)
* Fix closing all modals We used `Modal.closeCurrentModal()` in a bunch of places, in all cases (as far as I can see: it wasn't commented) we meant to close all open modals. This swaps that function for one that closes all open modals. Also types the close reason which claimed to be something in a comment, of course, was wrong because a load of places passed their own random string which was never used. * Force close modals * Try with minimal changes * Already had a method for this * Add test * More tests * Unused importsd
1 parent a7542dc commit dcf7643

File tree

8 files changed

+95
-17
lines changed

8 files changed

+95
-17
lines changed

src/Modal.tsx

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ limitations under the License.
1818
import React from "react";
1919
import ReactDOM from "react-dom";
2020
import classNames from "classnames";
21-
import { defer, sleep } from "matrix-js-sdk/src/utils";
21+
import { IDeferred, defer, sleep } from "matrix-js-sdk/src/utils";
2222
import { TypedEventEmitter } from "matrix-js-sdk/src/matrix";
2323
import { Glass } from "@vector-im/compound-web";
2424

@@ -47,11 +47,12 @@ export interface IModal<C extends ComponentType> {
4747
elem: React.ReactNode;
4848
className?: string;
4949
beforeClosePromise?: Promise<boolean>;
50-
closeReason?: string;
51-
onBeforeClose?(reason?: string): Promise<boolean>;
50+
closeReason?: ModalCloseReason;
51+
onBeforeClose?(reason?: ModalCloseReason): Promise<boolean>;
5252
onFinished: ComponentProps<C>["onFinished"];
5353
close(...args: Parameters<ComponentProps<C>["onFinished"]>): void;
5454
hidden?: boolean;
55+
deferred?: IDeferred<Parameters<ComponentProps<C>["onFinished"]>>;
5556
}
5657

5758
export interface IHandle<C extends ComponentType> {
@@ -73,6 +74,8 @@ type HandlerMap = {
7374
[ModalManagerEvent.Closed]: () => void;
7475
};
7576

77+
type ModalCloseReason = "backgroundClick";
78+
7679
export class ModalManager extends TypedEventEmitter<ModalManagerEvent, HandlerMap> {
7780
private counter = 0;
7881
// The modal to prioritise over all others. If this is set, only show
@@ -148,10 +151,14 @@ export class ModalManager extends TypedEventEmitter<ModalManagerEvent, HandlerMa
148151
}
149152

150153
/**
154+
* DEPRECATED.
155+
* This is used only for tests. They should be using forceCloseAllModals but that
156+
* caused a chunk of tests to fail, so for now they continue to use this.
157+
*
151158
* @param reason either "backgroundClick" or undefined
152159
* @return whether a modal was closed
153160
*/
154-
public closeCurrentModal(reason?: string): boolean {
161+
public closeCurrentModal(reason?: ModalCloseReason): boolean {
155162
const modal = this.getCurrentModal();
156163
if (!modal) {
157164
return false;
@@ -161,6 +168,22 @@ export class ModalManager extends TypedEventEmitter<ModalManagerEvent, HandlerMa
161168
return true;
162169
}
163170

171+
/**
172+
* Forces closes all open modals. The modals onBeforeClose function will not be
173+
* run and the modal will not have a chance to prevent closing. Intended for
174+
* situations like the user logging out of the app.
175+
*/
176+
public forceCloseAllModals(): void {
177+
for (const modal of this.modals) {
178+
modal.deferred?.resolve([]);
179+
if (modal.onFinished) modal.onFinished.apply(null);
180+
this.emitClosed();
181+
}
182+
183+
this.modals = [];
184+
this.reRender();
185+
}
186+
164187
private buildModal<C extends ComponentType>(
165188
prom: Promise<C>,
166189
props?: ComponentProps<C>,
@@ -199,7 +222,7 @@ export class ModalManager extends TypedEventEmitter<ModalManagerEvent, HandlerMa
199222
modal: IModal<C>,
200223
props?: ComponentProps<C>,
201224
): [IHandle<C>["close"], IHandle<C>["finished"]] {
202-
const deferred = defer<Parameters<ComponentProps<C>["onFinished"]>>();
225+
modal.deferred = defer<Parameters<ComponentProps<C>["onFinished"]>>();
203226
return [
204227
async (...args: Parameters<ComponentProps<C>["onFinished"]>): Promise<void> => {
205228
if (modal.beforeClosePromise) {
@@ -212,7 +235,7 @@ export class ModalManager extends TypedEventEmitter<ModalManagerEvent, HandlerMa
212235
return;
213236
}
214237
}
215-
deferred.resolve(args);
238+
modal.deferred?.resolve(args);
216239
if (props?.onFinished) props.onFinished.apply(null, args);
217240
const i = this.modals.indexOf(modal);
218241
if (i >= 0) {
@@ -236,7 +259,7 @@ export class ModalManager extends TypedEventEmitter<ModalManagerEvent, HandlerMa
236259
this.reRender();
237260
this.emitClosed();
238261
},
239-
deferred.promise,
262+
modal.deferred.promise,
240263
];
241264
}
242265

src/components/structures/LoggedInView.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -488,11 +488,15 @@ class LoggedInView extends React.Component<IProps, IState> {
488488
handled = true;
489489
break;
490490
case KeyBindingAction.GoToHome:
491+
// even if we cancel because there are modals open, we still
492+
// handled it: nothing else should happen.
493+
handled = true;
494+
if (Modal.hasDialogs()) {
495+
return;
496+
}
491497
dis.dispatch({
492498
action: Action.ViewHomePage,
493499
});
494-
Modal.closeCurrentModal("homeKeyboardShortcut");
495-
handled = true;
496500
break;
497501
case KeyBindingAction.ToggleSpacePanel:
498502
dis.fire(Action.ToggleSpacePanel);

src/components/structures/MatrixChat.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1544,7 +1544,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
15441544
if (Lifecycle.isLoggingOut()) return;
15451545

15461546
// A modal might have been open when we were logged out by the server
1547-
Modal.closeCurrentModal("Session.logged_out");
1547+
Modal.forceCloseAllModals();
15481548

15491549
if (errObj.httpStatus === 401 && errObj.data && errObj.data["soft_logout"]) {
15501550
logger.warn("Soft logout issued by server - avoiding data deletion");

test/Modal-test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/*
2+
Copyright 2024 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import Modal from "../src/Modal";
18+
import QuestionDialog from "../src/components/views/dialogs/QuestionDialog";
19+
20+
describe("Modal", () => {
21+
test("forceCloseAllModals should close all open modals", () => {
22+
Modal.createDialog(QuestionDialog, {
23+
title: "Test dialog",
24+
description: "This is a test dialog",
25+
button: "Word",
26+
});
27+
28+
expect(Modal.hasDialogs()).toBe(true);
29+
Modal.forceCloseAllModals();
30+
expect(Modal.hasDialogs()).toBe(false);
31+
});
32+
});

test/components/structures/LoggedInView-test.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import defaultDispatcher from "../../../src/dispatcher/dispatcher";
3131
import SettingsStore from "../../../src/settings/SettingsStore";
3232
import { SettingLevel } from "../../../src/settings/SettingLevel";
3333
import { Action } from "../../../src/dispatcher/actions";
34+
import Modal from "../../../src/Modal";
3435

3536
describe("<LoggedInView />", () => {
3637
const userId = "@alice:domain.org";
@@ -398,4 +399,22 @@ describe("<LoggedInView />", () => {
398399
await userEvent.keyboard("{Control>}f{/Control}");
399400
expect(defaultDispatcher.fire).toHaveBeenCalledWith(Action.FocusMessageSearch);
400401
});
402+
403+
it("should go home on home shortcut", async () => {
404+
jest.spyOn(defaultDispatcher, "dispatch");
405+
406+
getComponent();
407+
await userEvent.keyboard("{Control>}{Alt>}h</Alt>{/Control}");
408+
expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ action: Action.ViewHomePage });
409+
});
410+
411+
it("should ignore home shortcut if dialogs are open", async () => {
412+
jest.spyOn(defaultDispatcher, "dispatch");
413+
jest.spyOn(Modal, "hasDialogs").mockReturnValue(true);
414+
415+
getComponent();
416+
417+
await userEvent.keyboard("{Control>}{Alt>}h</Alt>{/Control}");
418+
expect(defaultDispatcher.dispatch).not.toHaveBeenCalledWith({ action: Action.ViewHomePage });
419+
});
401420
});

test/components/views/context_menus/RoomGeneralContextMenu-test.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import { mkMessage, stubClient } from "../../../test-utils/test-utils";
3636
import { shouldShowComponent } from "../../../../src/customisations/helpers/UIComponents";
3737
import { UIComponent } from "../../../../src/settings/UIFeature";
3838
import SettingsStore from "../../../../src/settings/SettingsStore";
39-
import Modal from "../../../../src/Modal";
39+
import { clearAllModals } from "../../../test-utils";
4040

4141
jest.mock("../../../../src/customisations/helpers/UIComponents", () => ({
4242
shouldShowComponent: jest.fn(),
@@ -90,8 +90,8 @@ describe("RoomGeneralContextMenu", () => {
9090
onFinished = jest.fn();
9191
});
9292

93-
afterEach(() => {
94-
Modal.closeCurrentModal("force");
93+
afterEach(async () => {
94+
await clearAllModals();
9595
});
9696

9797
it("renders an empty context menu for archived rooms", async () => {

test/components/views/dialogs/InviteDialog-test.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { mocked, Mocked } from "jest-mock";
2525
import InviteDialog from "../../../../src/components/views/dialogs/InviteDialog";
2626
import { InviteKind } from "../../../../src/components/views/dialogs/InviteDialogTypes";
2727
import {
28+
clearAllModals,
2829
filterConsole,
2930
flushPromises,
3031
getMockClientWithEventEmitter,
@@ -40,7 +41,6 @@ import { SdkContextClass } from "../../../../src/contexts/SDKContext";
4041
import { IProfileInfo } from "../../../../src/hooks/useProfileInfo";
4142
import { DirectoryMember, startDmOnFirstMessage } from "../../../../src/utils/direct-messages";
4243
import SettingsStore from "../../../../src/settings/SettingsStore";
43-
import Modal from "../../../../src/Modal";
4444

4545
const mockGetAccessToken = jest.fn().mockResolvedValue("getAccessToken");
4646
jest.mock("../../../../src/IdentityAuthClient", () =>
@@ -178,8 +178,8 @@ describe("InviteDialog", () => {
178178
SdkContextClass.instance.client = mockClient;
179179
});
180180

181-
afterEach(() => {
182-
Modal.closeCurrentModal();
181+
afterEach(async () => {
182+
await clearAllModals();
183183
SdkContextClass.instance.onLoggedOut();
184184
SdkContextClass.instance.client = undefined;
185185
});

test/test-utils/utilities.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ export const clearAllModals = async (): Promise<void> => {
204204
// Prevent modals from leaking and polluting other tests
205205
let keepClosingModals = true;
206206
while (keepClosingModals) {
207-
keepClosingModals = Modal.closeCurrentModal("End of test (clean-up)");
207+
keepClosingModals = Modal.closeCurrentModal();
208208

209209
// Then wait for the screen to update (probably React rerender and async/await).
210210
// Important for tests using Jest fake timers to not get into an infinite loop

0 commit comments

Comments
 (0)