Skip to content

Commit 10569bc

Browse files
committed
Open a "progress" dialog while invites are being sent
1 parent 3deb1d5 commit 10569bc

File tree

4 files changed

+119
-39
lines changed

4 files changed

+119
-39
lines changed

src/components/views/dialogs/InviteDialog.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -623,7 +623,10 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
623623
}
624624

625625
try {
626-
const result = await inviteMultipleToRoom(cli, this.props.roomId, targetIds);
626+
const result = await inviteMultipleToRoom(cli, this.props.roomId, targetIds, {
627+
// We show our own progress body, so don't pop up a separate dialog.
628+
inhibitProgressDialog: true,
629+
});
627630
if (!this.shouldAbortAfterInviteError(result, room)) {
628631
// handles setting error message too
629632
this.props.onFinished(true);
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/*
2+
Copyright 2025 New Vector Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import React from "react";
9+
10+
import Modal from "../../../Modal.tsx";
11+
import InviteProgressBody from "./InviteProgressBody.tsx";
12+
13+
interface Props {
14+
onFinished: () => void;
15+
}
16+
17+
/** A Modal dialog that pops up while room invites are being sent. */
18+
const InviteProgressDialog: React.FC<Props> = (props) => {
19+
return <InviteProgressBody />;
20+
};
21+
22+
/**
23+
* Open the invite progress dialog.
24+
*
25+
* Returns a callback which will close the dialog again.
26+
*/
27+
export function openInviteProgressDialog(): () => void {
28+
const onBeforeClose = async (reason?: string): Promise<boolean> => {
29+
// Inhibit closing via background click
30+
return reason != "backgroundClick";
31+
};
32+
33+
const { close } = Modal.createDialog(
34+
InviteProgressDialog,
35+
/* props */ {},
36+
/* className */ undefined,
37+
/* isPriorityModal */ false,
38+
/* isStaticModal */ false,
39+
{ onBeforeClose },
40+
);
41+
return close;
42+
}

src/utils/MultiInviter.ts

Lines changed: 50 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import Modal from "../Modal";
1616
import SettingsStore from "../settings/SettingsStore";
1717
import AskInviteAnywayDialog from "../components/views/dialogs/AskInviteAnywayDialog";
1818
import ConfirmUserActionDialog from "../components/views/dialogs/ConfirmUserActionDialog";
19+
import { openInviteProgressDialog } from "../components/views/dialogs/InviteProgressDialog.tsx";
1920

2021
export enum InviteState {
2122
Invited = "invited",
@@ -44,6 +45,12 @@ const USER_BANNED = "IO.ELEMENT.BANNED";
4445
export interface MultiInviterOptions {
4546
/** Optional callback, fired after each invite */
4647
progressCallback?: () => void;
48+
49+
/**
50+
* By default, we will pop up a "Preparing invitations..." dialog while the invites are being sent. Set this to
51+
* `true` to inhibit it (in which case, you probably want to implement another bit of feedback UI).
52+
*/
53+
inhibitProgressDialog?: boolean;
4754
}
4855

4956
/**
@@ -88,49 +95,59 @@ export default class MultiInviter {
8895
this.addresses.push(...addresses);
8996
this.reason = reason;
9097

91-
for (const addr of this.addresses) {
92-
if (getAddressType(addr) === null) {
93-
this.completionStates[addr] = InviteState.Error;
94-
this.errors[addr] = {
95-
errcode: "M_INVALID",
96-
errorText: _t("invite|invalid_address"),
97-
};
98-
}
98+
let closeDialog: (() => void) | undefined;
99+
if (!this.options.inhibitProgressDialog) {
100+
closeDialog = openInviteProgressDialog();
99101
}
100102

101-
for (const addr of this.addresses) {
102-
// don't try to invite it if it's an invalid address
103-
// (it will already be marked as an error though,
104-
// so no need to do so again)
105-
if (getAddressType(addr) === null) {
106-
continue;
103+
try {
104+
for (const addr of this.addresses) {
105+
if (getAddressType(addr) === null) {
106+
this.completionStates[addr] = InviteState.Error;
107+
this.errors[addr] = {
108+
errcode: "M_INVALID",
109+
errorText: _t("invite|invalid_address"),
110+
};
111+
}
107112
}
108113

109-
// don't re-invite (there's no way in the UI to do this, but
110-
// for sanity's sake)
111-
if (this.completionStates[addr] === InviteState.Invited) {
112-
continue;
113-
}
114+
for (const addr of this.addresses) {
115+
// don't try to invite it if it's an invalid address
116+
// (it will already be marked as an error though,
117+
// so no need to do so again)
118+
if (getAddressType(addr) === null) {
119+
continue;
120+
}
121+
122+
// don't re-invite (there's no way in the UI to do this, but
123+
// for sanity's sake)
124+
if (this.completionStates[addr] === InviteState.Invited) {
125+
continue;
126+
}
114127

115-
await this.doInvite(addr, false);
128+
await this.doInvite(addr, false);
116129

117-
if (this._fatal) {
118-
// `doInvite` suffered a fatal error. The error should have been recorded in `errors`; it's up
119-
// to the caller to report back to the user.
120-
return this.completionStates;
130+
if (this._fatal) {
131+
// `doInvite` suffered a fatal error. The error should have been recorded in `errors`; it's up
132+
// to the caller to report back to the user.
133+
return this.completionStates;
134+
}
121135
}
122-
}
123136

124-
if (Object.keys(this.errors).length > 0) {
125-
// There were problems inviting some people - see if we can invite them
126-
// without caring if they exist or not.
127-
const unknownProfileUsers = Object.keys(this.errors).filter((a) =>
128-
UNKNOWN_PROFILE_ERRORS.includes(this.errors[a].errcode),
129-
);
137+
if (Object.keys(this.errors).length > 0) {
138+
// There were problems inviting some people - see if we can invite them
139+
// without caring if they exist or not.
140+
const unknownProfileUsers = Object.keys(this.errors).filter((a) =>
141+
UNKNOWN_PROFILE_ERRORS.includes(this.errors[a].errcode),
142+
);
130143

131-
if (unknownProfileUsers.length > 0) {
132-
await this.handleUnknownProfileUsers(unknownProfileUsers);
144+
if (unknownProfileUsers.length > 0) {
145+
await this.handleUnknownProfileUsers(unknownProfileUsers);
146+
}
133147
}
148+
} finally {
149+
// Remember to close the progress dialog, if we opened one.
150+
closeDialog?.();
134151
}
135152

136153
return this.completionStates;

test/unit-tests/utils/MultiInviter-test.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import Modal, { type ComponentType, type ComponentProps } from "../../../src/Mod
1515
import SettingsStore from "../../../src/settings/SettingsStore";
1616
import MultiInviter, { type CompletionStates } from "../../../src/utils/MultiInviter";
1717
import * as TestUtilsMatrix from "../../test-utils";
18-
import type AskInviteAnywayDialog from "../../../src/components/views/dialogs/AskInviteAnywayDialog";
18+
import AskInviteAnywayDialog from "../../../src/components/views/dialogs/AskInviteAnywayDialog";
1919
import ConfirmUserActionDialog from "../../../src/components/views/dialogs/ConfirmUserActionDialog";
2020

2121
const ROOMID = "!room:server";
@@ -55,11 +55,12 @@ const mockPromptBeforeInviteUnknownUsers = (value: boolean) => {
5555
};
5656

5757
const mockCreateTrackedDialog = (callbackName: "onInviteAnyways" | "onGiveUp") => {
58-
mocked(Modal.createDialog).mockImplementation(
59-
(Element: ComponentType, props?: ComponentProps<ComponentType>): any => {
58+
mocked(Modal.createDialog).mockImplementation((Element: ComponentType, props?: ComponentProps<ComponentType>) => {
59+
if (Element === AskInviteAnywayDialog) {
6060
(props as ComponentProps<typeof AskInviteAnywayDialog>)[callbackName]();
61-
},
62-
);
61+
}
62+
return { close: jest.fn(), finished: new Promise(() => {}) };
63+
});
6364
};
6465

6566
const expectAllInvitedResult = (result: CompletionStates) => {
@@ -76,6 +77,7 @@ describe("MultiInviter", () => {
7677

7778
beforeEach(() => {
7879
jest.resetAllMocks();
80+
mocked(Modal.createDialog).mockReturnValue({ close: jest.fn(), finished: new Promise(() => {}) });
7981

8082
TestUtilsMatrix.stubClient();
8183
client = MatrixClientPeg.safeGet() as jest.Mocked<MatrixClient>;
@@ -95,6 +97,22 @@ describe("MultiInviter", () => {
9597
});
9698

9799
describe("invite", () => {
100+
it("should show a progress dialog while the invite happens", async () => {
101+
const mockModalHandle = { close: jest.fn(), finished: new Promise<[]>(() => {}) };
102+
mocked(Modal.createDialog).mockReturnValue(mockModalHandle);
103+
104+
const invitePromise = Promise.withResolvers<{}>();
105+
client.invite.mockReturnValue(invitePromise.promise);
106+
107+
const resultPromise = inviter.invite([MXID1]);
108+
expect(Modal.createDialog).toHaveBeenCalledTimes(1);
109+
expect(mockModalHandle.close).not.toHaveBeenCalled();
110+
111+
invitePromise.resolve({});
112+
await resultPromise;
113+
expect(mockModalHandle.close).toHaveBeenCalled();
114+
});
115+
98116
describe("with promptBeforeInviteUnknownUsers = false", () => {
99117
beforeEach(() => mockPromptBeforeInviteUnknownUsers(false));
100118

0 commit comments

Comments
 (0)