Show a "progress" dialog while invites are being sent (#30561)

* InviteDialog: show some words and a spinner while invites are being sent

* MultiInviter-test: avoid building unhandled rejected promises

If we don't handle rejected promises, jest gets confused by them. Instead,
let's create them on-demand.

* Open a "progress" dialog while invites are being sent

* Inhibit invite progress dialog when RoomUpgradeWarning dialog is kept open

... otherwise the `RoomUpgradeWarning` dialog disappears during the invites,
and the tests that assert that it is showing the correct thing fail.
 enter the commit message for your changes. Lines starting

* Switch to compound CSS variables instead of old pcss vars

* update playwright screenshots

* Revert "update playwright screenshots"

This reverts commit b0a15d97f35a088fe5b67009085eab46be1316fd.

* Another go at updating screenshots

* Address review comments

* remove redundant Props
This commit is contained in:
Richard van der Hoff
2025-08-22 16:10:42 +01:00
committed by GitHub
parent c842b615db
commit e8c88918cb
15 changed files with 271 additions and 81 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

View File

@@ -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";

View File

@@ -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 {

View File

@@ -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);
}
}

View File

@@ -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<Props, IInviteDial
}
try {
const result = await inviteMultipleToRoom(cli, this.props.roomId, targetIds);
const result = await inviteMultipleToRoom(cli, this.props.roomId, targetIds, {
// We show our own progress body, so don't pop up a separate dialog.
inhibitProgressDialog: true,
});
if (!this.shouldAbortAfterInviteError(result, room)) {
// handles setting error message too
this.props.onFinished(true);
@@ -1328,11 +1337,6 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
* "CallTransfer" one.
*/
private renderMainTab(): JSX.Element {
let spinner: JSX.Element | undefined;
if (this.state.busy) {
spinner = <Spinner w={20} h={20} />;
}
let helpText;
let buttonText;
let goButtonFn: (() => Promise<void>) | null = null;
@@ -1437,12 +1441,9 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
<p className="mx_InviteDialog_helpText">{helpText}</p>
<div className="mx_InviteDialog_addressBar">
{this.renderEditor()}
<div className="mx_InviteDialog_buttonAndSpinner">
{goButton}
{spinner}
</div>
{goButton}
</div>
{this.renderSuggestions()}
{this.state.busy ? <InviteProgressBody /> : this.renderSuggestions()}
</React.Fragment>
);
}

View File

@@ -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 (
<div className="mx_InviteProgressBody">
<InlineSpinner w={32} h={32} />
<h1>{_t("invite|progress|preparing")}</h1>
{_t("invite|progress|dont_close")}
</div>
);
};
export default InviteProgressBody;

View File

@@ -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 <InviteProgressBody />;
};
/**
* Open the invite progress dialog.
*
* Returns a callback which will close the dialog again.
*/
export function openInviteProgressDialog(): () => void {
const onBeforeClose = async (reason?: string): Promise<boolean> => {
// Inhibit closing via background click
return reason != "backgroundClick";
};
const { close } = Modal.createDialog(
InviteProgressDialog,
/* props */ {},
/* className */ undefined,
/* isPriorityModal */ false,
/* isStaticModal */ false,
{ onBeforeClose },
);
return close;
}

View File

@@ -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<JoinRuleSettingsProps> = ({
opts: IFinishedOpts,
fn: (progressText: string, progress: number, total: number) => void,
): Promise<void> => {
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<JoinRuleSettingsProps> = ({
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?.();

View File

@@ -1366,6 +1366,10 @@
"name_email_mxid_share_space": "Invite someone using their name, email address, username (like <userId/>) or <a>share this space</a>.",
"name_mxid_share_room": "Invite someone using their name, username (like <userId/>) or <a>share this room</a>.",
"name_mxid_share_space": "Invite someone using their name, username (like <userId/>) or <a>share this space</a>.",
"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 <RoomName/>",
"room_failed_partial_title": "Some invites couldn't be sent",

View File

@@ -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);
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;
}
}
await this.doInvite(addr, false);
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 (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 (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;

View File

@@ -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<string> {
const cli = room.client;
let spinnerModal: IHandle<any> | 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<void> {
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);
}

View File

@@ -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(<InviteDialog kind={InviteKind.Invite} roomId={roomId} onFinished={jest.fn()} />);
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(<InviteDialog kind={InviteKind.Dm} onFinished={jest.fn()} />);

View File

@@ -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(<InviteProgressBody />);
expect(asFragment()).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,23 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`InviteProgressBody should match snapshot 1`] = `
<DocumentFragment>
<div
class="mx_InviteProgressBody"
>
<div
class="mx_InlineSpinner"
>
<div
aria-label="Loading…"
class="mx_InlineSpinner_icon mx_Spinner_icon"
style="width: 32px; height: 32px;"
/>
</div>
<h1>
Preparing invitations...
</h1>
Do not close the app until finished.
</div>
</DocumentFragment>
`;

View File

@@ -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<string, Promise<any>> = {
[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<string, () => {}> = {
[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<ComponentType>): any => {
mocked(Modal.createDialog).mockImplementation((Element: ComponentType, props?: ComponentProps<ComponentType>) => {
if (Element === AskInviteAnywayDialog) {
(props as ComponentProps<typeof AskInviteAnywayDialog>)[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<MatrixClient>;
@@ -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));