Compare commits

...

8 Commits

Author SHA1 Message Date
Richard van der Hoff
2db2a77d56 Another go at updating screenshots 2025-08-15 12:39:01 +01:00
Richard van der Hoff
fc3a52cd4c Revert "update playwright screenshots"
This reverts commit b0a15d97f3.
2025-08-15 12:18:53 +01:00
Richard van der Hoff
b0a15d97f3 update playwright screenshots 2025-08-15 11:04:45 +01:00
Richard van der Hoff
a646f3575f Switch to compound CSS variables instead of old pcss vars 2025-08-14 14:45:34 +01:00
Richard van der Hoff
0541b7a961 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
2025-08-14 12:36:08 +01:00
Richard van der Hoff
58f1893997 Open a "progress" dialog while invites are being sent 2025-08-13 17:58:56 +01:00
Richard van der Hoff
a562b2928a 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.
2025-08-13 17:58:56 +01:00
Richard van der Hoff
f43fadd9b8 InviteDialog: show some words and a spinner while invites are being sent 2025-08-13 17:58:56 +01:00
15 changed files with 284 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,42 @@
/*
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";
interface Props {
onFinished: () => void;
}
/** A Modal dialog that pops up while room invites are being sent. */
const InviteProgressDialog: React.FC<Props> = (props) => {
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,27 @@
/*
Copyright 2025 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
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));