diff --git a/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-dm-without-user-linux.png b/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-dm-without-user-linux.png
index 86cb11aad9a..f463282be77 100644
Binary files a/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-dm-without-user-linux.png and b/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-dm-without-user-linux.png differ
diff --git a/res/css/_components.pcss b/res/css/_components.pcss
index b140fd1d7ec..602885546ee 100644
--- a/res/css/_components.pcss
+++ b/res/css/_components.pcss
@@ -142,6 +142,7 @@
@import "./views/dialogs/_GenericFeatureFeedbackDialog.pcss";
@import "./views/dialogs/_IncomingSasDialog.pcss";
@import "./views/dialogs/_InviteDialog.pcss";
+@import "./views/dialogs/_InviteProgressBody.pcss";
@import "./views/dialogs/_JoinRuleDropdown.pcss";
@import "./views/dialogs/_LeaveSpaceDialog.pcss";
@import "./views/dialogs/_LocationViewDialog.pcss";
diff --git a/res/css/views/dialogs/_InviteDialog.pcss b/res/css/views/dialogs/_InviteDialog.pcss
index 70a8cdc6087..0f952049cf5 100644
--- a/res/css/views/dialogs/_InviteDialog.pcss
+++ b/res/css/views/dialogs/_InviteDialog.pcss
@@ -63,17 +63,6 @@ Please see LICENSE files in the repository root for full details.
height: 25px;
line-height: $font-25px;
}
-
- .mx_InviteDialog_buttonAndSpinner {
- .mx_Spinner {
- /* Width and height are required to trick the layout engine. */
- width: 20px;
- height: 20px;
- margin-inline-start: 5px;
- display: inline-block;
- vertical-align: middle;
- }
- }
}
.mx_InviteDialog_section {
@@ -218,6 +207,10 @@ Please see LICENSE files in the repository root for full details.
flex-direction: column;
flex-grow: 1;
overflow: hidden;
+
+ .mx_InviteProgressBody {
+ margin-top: var(--cpd-space-12x);
+ }
}
.mx_InviteDialog_transfer {
diff --git a/res/css/views/dialogs/_InviteProgressBody.pcss b/res/css/views/dialogs/_InviteProgressBody.pcss
new file mode 100644
index 00000000000..e3069a133c0
--- /dev/null
+++ b/res/css/views/dialogs/_InviteProgressBody.pcss
@@ -0,0 +1,16 @@
+/*
+Copyright 2025 New Vector Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
+Please see LICENSE files in the repository root for full details.
+*/
+
+.mx_InviteProgressBody {
+ text-align: center;
+ font: var(--cpd-font-body-lg-regular);
+
+ h1 {
+ color: var(--cpd-color-text-primary);
+ font: var(--cpd-font-heading-sm-semibold);
+ }
+}
diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx
index c07cdf332bd..73049122dc1 100644
--- a/src/components/views/dialogs/InviteDialog.tsx
+++ b/src/components/views/dialogs/InviteDialog.tsx
@@ -40,7 +40,6 @@ import Field from "../elements/Field";
import TabbedView, { Tab, TabLocation } from "../../structures/TabbedView";
import Dialpad from "../voip/DialPad";
import QuestionDialog from "./QuestionDialog";
-import Spinner from "../elements/Spinner";
import BaseDialog from "./BaseDialog";
import DialPadBackspaceButton from "../elements/DialPadBackspaceButton";
import LegacyCallHandler from "../../../LegacyCallHandler";
@@ -65,6 +64,7 @@ import { UNKNOWN_PROFILE_ERRORS } from "../../../utils/MultiInviter";
import AskInviteAnywayDialog, { type UnknownProfiles } from "./AskInviteAnywayDialog";
import { SdkContextClass } from "../../../contexts/SDKContext";
import { type UserProfilesStore } from "../../../stores/UserProfilesStore";
+import InviteProgressBody from "./InviteProgressBody.tsx";
// we have a number of types defined from the Matrix spec which can't reasonably be altered here.
/* eslint-disable camelcase */
@@ -329,8 +329,14 @@ interface IInviteDialogState {
dialPadValue: string;
currentTabId: TabId;
- // These two flags are used for the 'Go' button to communicate what is going on.
+ /**
+ * True if we are sending the invites.
+ *
+ * We will grey out the action button, hide the suggestions, and display a spinner.
+ */
busy: boolean;
+
+ /** Error from the last attempt to send invites. */
errorText?: string;
}
@@ -617,7 +623,10 @@ export default class InviteDialog extends React.PureComponent;
- }
-
let helpText;
let buttonText;
let goButtonFn: (() => Promise) | null = null;
@@ -1437,12 +1441,9 @@ export default class InviteDialog extends React.PureComponent{helpText}
{this.renderEditor()}
-
- {goButton}
- {spinner}
-
+ {goButton}
- {this.renderSuggestions()}
+ {this.state.busy ? : this.renderSuggestions()}
);
}
diff --git a/src/components/views/dialogs/InviteProgressBody.tsx b/src/components/views/dialogs/InviteProgressBody.tsx
new file mode 100644
index 00000000000..a61c0d5922a
--- /dev/null
+++ b/src/components/views/dialogs/InviteProgressBody.tsx
@@ -0,0 +1,24 @@
+/*
+Copyright 2025 New Vector Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
+Please see LICENSE files in the repository root for full details.
+*/
+
+import React from "react";
+
+import InlineSpinner from "../elements/InlineSpinner";
+import { _t } from "../../../languageHandler";
+
+/** The common body of components that show the progress of sending room invites. */
+const InviteProgressBody: React.FC = () => {
+ return (
+
+
+
{_t("invite|progress|preparing")}
+ {_t("invite|progress|dont_close")}
+
+ );
+};
+
+export default InviteProgressBody;
diff --git a/src/components/views/dialogs/InviteProgressDialog.tsx b/src/components/views/dialogs/InviteProgressDialog.tsx
new file mode 100644
index 00000000000..fe62afa8d8b
--- /dev/null
+++ b/src/components/views/dialogs/InviteProgressDialog.tsx
@@ -0,0 +1,38 @@
+/*
+Copyright 2025 New Vector Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
+Please see LICENSE files in the repository root for full details.
+*/
+
+import React from "react";
+
+import Modal from "../../../Modal.tsx";
+import InviteProgressBody from "./InviteProgressBody.tsx";
+
+/** A Modal dialog that pops up while room invites are being sent. */
+const InviteProgressDialog: React.FC = (_) => {
+ return ;
+};
+
+/**
+ * Open the invite progress dialog.
+ *
+ * Returns a callback which will close the dialog again.
+ */
+export function openInviteProgressDialog(): () => void {
+ const onBeforeClose = async (reason?: string): Promise => {
+ // Inhibit closing via background click
+ return reason != "backgroundClick";
+ };
+
+ const { close } = Modal.createDialog(
+ InviteProgressDialog,
+ /* props */ {},
+ /* className */ undefined,
+ /* isPriorityModal */ false,
+ /* isStaticModal */ false,
+ { onBeforeClose },
+ );
+ return close;
+}
diff --git a/src/components/views/settings/JoinRuleSettings.tsx b/src/components/views/settings/JoinRuleSettings.tsx
index 43f8ab9ff6a..400dc7a8653 100644
--- a/src/components/views/settings/JoinRuleSettings.tsx
+++ b/src/components/views/settings/JoinRuleSettings.tsx
@@ -18,7 +18,7 @@ import SpaceStore from "../../../stores/spaces/SpaceStore";
import Modal from "../../../Modal";
import ManageRestrictedJoinRuleDialog from "../dialogs/ManageRestrictedJoinRuleDialog";
import RoomUpgradeWarningDialog, { type IFinishedOpts } from "../dialogs/RoomUpgradeWarningDialog";
-import { upgradeRoom } from "../../../utils/RoomUpgrade";
+import { type RoomUpgradeProgress, upgradeRoom } from "../../../utils/RoomUpgrade";
import { arrayHasDiff } from "../../../utils/arrays";
import { useLocalEcho } from "../../../hooks/useLocalEcho";
import dis from "../../../dispatcher/dispatcher";
@@ -120,7 +120,7 @@ const JoinRuleSettings: React.FC = ({
opts: IFinishedOpts,
fn: (progressText: string, progress: number, total: number) => void,
): Promise => {
- const roomId = await upgradeRoom(room, targetVersion, opts.invite, true, true, true, (progress) => {
+ const progressCallback = (progress: RoomUpgradeProgress): void => {
const total = 2 + progress.updateSpacesTotal + progress.inviteUsersTotal;
if (!progress.roomUpgraded) {
fn(_t("room_settings|security|join_rule_upgrade_upgrading_room"), 0, total);
@@ -151,7 +151,20 @@ const JoinRuleSettings: React.FC = ({
total,
);
}
- });
+ };
+ const roomId = await upgradeRoom(
+ room,
+ targetVersion,
+ opts.invite,
+ true,
+ true,
+ true,
+ progressCallback,
+
+ // We want to keep the RoomUpgradeDialog open during the upgrade, so don't replace it with the
+ // invite progress dialog.
+ /* inhibitInviteProgressDialog: */ true,
+ );
closeSettingsFn?.();
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 79815439f0e..abca2b36d73 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -1366,6 +1366,10 @@
"name_email_mxid_share_space": "Invite someone using their name, email address, username (like ) or share this space.",
"name_mxid_share_room": "Invite someone using their name, username (like ) or share this room.",
"name_mxid_share_space": "Invite someone using their name, username (like ) or share this space.",
+ "progress": {
+ "dont_close": "Do not close the app until finished.",
+ "preparing": "Preparing invitations..."
+ },
"recents_section": "Recent Conversations",
"room_failed_partial": "We sent the others, but the below people couldn't be invited to ",
"room_failed_partial_title": "Some invites couldn't be sent",
diff --git a/src/utils/MultiInviter.ts b/src/utils/MultiInviter.ts
index a41967b5d35..46fd84cc269 100644
--- a/src/utils/MultiInviter.ts
+++ b/src/utils/MultiInviter.ts
@@ -16,6 +16,7 @@ import Modal from "../Modal";
import SettingsStore from "../settings/SettingsStore";
import AskInviteAnywayDialog from "../components/views/dialogs/AskInviteAnywayDialog";
import ConfirmUserActionDialog from "../components/views/dialogs/ConfirmUserActionDialog";
+import { openInviteProgressDialog } from "../components/views/dialogs/InviteProgressDialog.tsx";
export enum InviteState {
Invited = "invited",
@@ -44,6 +45,12 @@ const USER_BANNED = "IO.ELEMENT.BANNED";
export interface MultiInviterOptions {
/** Optional callback, fired after each invite */
progressCallback?: () => void;
+
+ /**
+ * By default, we will pop up a "Preparing invitations..." dialog while the invites are being sent. Set this to
+ * `true` to inhibit it (in which case, you probably want to implement another bit of feedback UI).
+ */
+ inhibitProgressDialog?: boolean;
}
/**
@@ -88,49 +95,59 @@ export default class MultiInviter {
this.addresses.push(...addresses);
this.reason = reason;
- for (const addr of this.addresses) {
- if (getAddressType(addr) === null) {
- this.completionStates[addr] = InviteState.Error;
- this.errors[addr] = {
- errcode: "M_INVALID",
- errorText: _t("invite|invalid_address"),
- };
- }
+ let closeDialog: (() => void) | undefined;
+ if (!this.options.inhibitProgressDialog) {
+ closeDialog = openInviteProgressDialog();
}
- for (const addr of this.addresses) {
- // don't try to invite it if it's an invalid address
- // (it will already be marked as an error though,
- // so no need to do so again)
- if (getAddressType(addr) === null) {
- continue;
+ try {
+ for (const addr of this.addresses) {
+ if (getAddressType(addr) === null) {
+ this.completionStates[addr] = InviteState.Error;
+ this.errors[addr] = {
+ errcode: "M_INVALID",
+ errorText: _t("invite|invalid_address"),
+ };
+ }
}
- // don't re-invite (there's no way in the UI to do this, but
- // for sanity's sake)
- if (this.completionStates[addr] === InviteState.Invited) {
- continue;
- }
+ for (const addr of this.addresses) {
+ // don't try to invite it if it's an invalid address
+ // (it will already be marked as an error though,
+ // so no need to do so again)
+ if (getAddressType(addr) === null) {
+ continue;
+ }
+
+ // don't re-invite (there's no way in the UI to do this, but
+ // for sanity's sake)
+ if (this.completionStates[addr] === InviteState.Invited) {
+ continue;
+ }
- await this.doInvite(addr, false);
+ await this.doInvite(addr, false);
- if (this._fatal) {
- // `doInvite` suffered a fatal error. The error should have been recorded in `errors`; it's up
- // to the caller to report back to the user.
- return this.completionStates;
+ if (this._fatal) {
+ // `doInvite` suffered a fatal error. The error should have been recorded in `errors`; it's up
+ // to the caller to report back to the user.
+ return this.completionStates;
+ }
}
- }
- if (Object.keys(this.errors).length > 0) {
- // There were problems inviting some people - see if we can invite them
- // without caring if they exist or not.
- const unknownProfileUsers = Object.keys(this.errors).filter((a) =>
- UNKNOWN_PROFILE_ERRORS.includes(this.errors[a].errcode),
- );
+ if (Object.keys(this.errors).length > 0) {
+ // There were problems inviting some people - see if we can invite them
+ // without caring if they exist or not.
+ const unknownProfileUsers = Object.keys(this.errors).filter((a) =>
+ UNKNOWN_PROFILE_ERRORS.includes(this.errors[a].errcode),
+ );
- if (unknownProfileUsers.length > 0) {
- await this.handleUnknownProfileUsers(unknownProfileUsers);
+ if (unknownProfileUsers.length > 0) {
+ await this.handleUnknownProfileUsers(unknownProfileUsers);
+ }
}
+ } finally {
+ // Remember to close the progress dialog, if we opened one.
+ closeDialog?.();
}
return this.completionStates;
diff --git a/src/utils/RoomUpgrade.ts b/src/utils/RoomUpgrade.ts
index e4ccfdb7d09..0c476a2dddb 100644
--- a/src/utils/RoomUpgrade.ts
+++ b/src/utils/RoomUpgrade.ts
@@ -16,8 +16,9 @@ import { _t } from "../languageHandler";
import ErrorDialog from "../components/views/dialogs/ErrorDialog";
import SpaceStore from "../stores/spaces/SpaceStore";
import Spinner from "../components/views/elements/Spinner";
+import type { MultiInviterOptions } from "./MultiInviter";
-interface IProgress {
+export interface RoomUpgradeProgress {
roomUpgraded: boolean;
roomSynced?: boolean;
inviteUsersProgress?: number;
@@ -50,7 +51,8 @@ export async function upgradeRoom(
handleError = true,
updateSpaces = true,
awaitRoom = false,
- progressCallback?: (progress: IProgress) => void,
+ progressCallback?: (progress: RoomUpgradeProgress) => void,
+ inhibitInviteProgressDialog = false,
): Promise {
const cli = room.client;
let spinnerModal: IHandle | undefined;
@@ -77,7 +79,7 @@ export async function upgradeRoom(
) as Room[];
}
- const progress: IProgress = {
+ const progress: RoomUpgradeProgress = {
roomUpgraded: false,
roomSynced: awaitRoom || inviteUsers ? false : undefined,
inviteUsersProgress: inviteUsers ? 0 : undefined,
@@ -112,9 +114,12 @@ export async function upgradeRoom(
if (toInvite.length > 0) {
// Errors are handled internally to this function
- await inviteUsersToRoom(cli, newRoomId, toInvite, () => {
- progress.inviteUsersProgress!++;
- progressCallback?.(progress);
+ await inviteUsersToRoom(cli, newRoomId, toInvite, {
+ progressCallback: () => {
+ progress.inviteUsersProgress!++;
+ progressCallback?.(progress);
+ },
+ inhibitProgressDialog: inhibitInviteProgressDialog,
});
}
@@ -150,9 +155,9 @@ async function inviteUsersToRoom(
client: MatrixClient,
roomId: string,
userIds: string[],
- progressCallback?: () => void,
+ inviteOptions: MultiInviterOptions,
): Promise {
- const result = await inviteMultipleToRoom(client, roomId, userIds, { progressCallback });
+ const result = await inviteMultipleToRoom(client, roomId, userIds, inviteOptions);
const room = client.getRoom(roomId)!;
showAnyInviteErrors(result.states, room, result.inviter);
}
diff --git a/test/unit-tests/components/views/dialogs/InviteDialog-test.tsx b/test/unit-tests/components/views/dialogs/InviteDialog-test.tsx
index 8777d7cb80f..2d7669a5dc3 100644
--- a/test/unit-tests/components/views/dialogs/InviteDialog-test.tsx
+++ b/test/unit-tests/components/views/dialogs/InviteDialog-test.tsx
@@ -137,6 +137,7 @@ describe("InviteDialog", () => {
supportsThreads: jest.fn().mockReturnValue(false),
isInitialSyncComplete: jest.fn().mockReturnValue(true),
getClientWellKnown: jest.fn().mockResolvedValue({}),
+ invite: jest.fn(),
});
SdkConfig.put({ validated_server_config: {} as ValidatedServerConfig } as IConfigOptions);
DMRoomMap.makeShared(mockClient);
@@ -406,6 +407,18 @@ describe("InviteDialog", () => {
expect(tile).toBeInTheDocument();
});
+ describe("while the invite is in progress", () => {
+ it("should show a spinner", async () => {
+ mockClient.invite.mockReturnValue(new Promise(() => {}));
+
+ render();
+ await enterIntoSearchField(bobId);
+ await userEvent.click(screen.getByRole("button", { name: "Invite" }));
+
+ await screen.findByText("Preparing invitations...");
+ });
+ });
+
describe("when inviting a user with an unknown profile", () => {
beforeEach(async () => {
render();
diff --git a/test/unit-tests/components/views/dialogs/InviteProgressBody-test.tsx b/test/unit-tests/components/views/dialogs/InviteProgressBody-test.tsx
new file mode 100644
index 00000000000..021a2fb6e7e
--- /dev/null
+++ b/test/unit-tests/components/views/dialogs/InviteProgressBody-test.tsx
@@ -0,0 +1,18 @@
+/*
+Copyright 2025 New Vector Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
+Please see LICENSE files in the repository root for full details.
+*/
+
+import React from "react";
+import { render } from "jest-matrix-react";
+
+import InviteProgressBody from "../../../../../src/components/views/dialogs/InviteProgressBody.tsx";
+
+describe("InviteProgressBody", () => {
+ it("should match snapshot", () => {
+ const { asFragment } = render();
+ expect(asFragment()).toMatchSnapshot();
+ });
+});
diff --git a/test/unit-tests/components/views/dialogs/__snapshots__/InviteProgressBody-test.tsx.snap b/test/unit-tests/components/views/dialogs/__snapshots__/InviteProgressBody-test.tsx.snap
new file mode 100644
index 00000000000..cd849386e27
--- /dev/null
+++ b/test/unit-tests/components/views/dialogs/__snapshots__/InviteProgressBody-test.tsx.snap
@@ -0,0 +1,23 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`InviteProgressBody should match snapshot 1`] = `
+
+
+
+
+ Preparing invitations...
+
+ Do not close the app until finished.
+
+
+`;
diff --git a/test/unit-tests/utils/MultiInviter-test.ts b/test/unit-tests/utils/MultiInviter-test.ts
index 8fcc6731433..b898ea2a244 100644
--- a/test/unit-tests/utils/MultiInviter-test.ts
+++ b/test/unit-tests/utils/MultiInviter-test.ts
@@ -15,7 +15,7 @@ import Modal, { type ComponentType, type ComponentProps } from "../../../src/Mod
import SettingsStore from "../../../src/settings/SettingsStore";
import MultiInviter, { type CompletionStates } from "../../../src/utils/MultiInviter";
import * as TestUtilsMatrix from "../../test-utils";
-import type AskInviteAnywayDialog from "../../../src/components/views/dialogs/AskInviteAnywayDialog";
+import AskInviteAnywayDialog from "../../../src/components/views/dialogs/AskInviteAnywayDialog";
import ConfirmUserActionDialog from "../../../src/components/views/dialogs/ConfirmUserActionDialog";
const ROOMID = "!room:server";
@@ -24,10 +24,14 @@ const MXID1 = "@user1:server";
const MXID2 = "@user2:server";
const MXID3 = "@user3:server";
-const MXID_PROFILE_STATES: Record> = {
- [MXID1]: Promise.resolve({}),
- [MXID2]: Promise.reject(new MatrixError({ errcode: "M_FORBIDDEN" })),
- [MXID3]: Promise.reject(new MatrixError({ errcode: "M_NOT_FOUND" })),
+const MXID_PROFILE_STATES: Record {}> = {
+ [MXID1]: () => ({}),
+ [MXID2]: () => {
+ throw new MatrixError({ errcode: "M_FORBIDDEN" });
+ },
+ [MXID3]: () => {
+ throw new MatrixError({ errcode: "M_NOT_FOUND" });
+ },
};
jest.mock("../../../src/Modal", () => ({
@@ -51,11 +55,12 @@ const mockPromptBeforeInviteUnknownUsers = (value: boolean) => {
};
const mockCreateTrackedDialog = (callbackName: "onInviteAnyways" | "onGiveUp") => {
- mocked(Modal.createDialog).mockImplementation(
- (Element: ComponentType, props?: ComponentProps): any => {
+ mocked(Modal.createDialog).mockImplementation((Element: ComponentType, props?: ComponentProps) => {
+ if (Element === AskInviteAnywayDialog) {
(props as ComponentProps)[callbackName]();
- },
- );
+ }
+ return { close: jest.fn(), finished: new Promise(() => {}) };
+ });
};
const expectAllInvitedResult = (result: CompletionStates) => {
@@ -72,6 +77,7 @@ describe("MultiInviter", () => {
beforeEach(() => {
jest.resetAllMocks();
+ mocked(Modal.createDialog).mockReturnValue({ close: jest.fn(), finished: new Promise(() => {}) });
TestUtilsMatrix.stubClient();
client = MatrixClientPeg.safeGet() as jest.Mocked;
@@ -80,8 +86,10 @@ describe("MultiInviter", () => {
client.invite.mockResolvedValue({});
client.getProfileInfo = jest.fn();
- client.getProfileInfo.mockImplementation((userId: string) => {
- return MXID_PROFILE_STATES[userId] || Promise.reject();
+ client.getProfileInfo.mockImplementation(async (userId: string) => {
+ const m = MXID_PROFILE_STATES[userId];
+ if (m) return m();
+ throw new Error();
});
client.unban = jest.fn();
@@ -89,6 +97,22 @@ describe("MultiInviter", () => {
});
describe("invite", () => {
+ it("should show a progress dialog while the invite happens", async () => {
+ const mockModalHandle = { close: jest.fn(), finished: new Promise<[]>(() => {}) };
+ mocked(Modal.createDialog).mockReturnValue(mockModalHandle);
+
+ const invitePromise = Promise.withResolvers<{}>();
+ client.invite.mockReturnValue(invitePromise.promise);
+
+ const resultPromise = inviter.invite([MXID1]);
+ expect(Modal.createDialog).toHaveBeenCalledTimes(1);
+ expect(mockModalHandle.close).not.toHaveBeenCalled();
+
+ invitePromise.resolve({});
+ await resultPromise;
+ expect(mockModalHandle.close).toHaveBeenCalled();
+ });
+
describe("with promptBeforeInviteUnknownUsers = false", () => {
beforeEach(() => mockPromptBeforeInviteUnknownUsers(false));