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:
committed by
GitHub
parent
c842b615db
commit
e8c88918cb
Binary file not shown.
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
@@ -142,6 +142,7 @@
|
|||||||
@import "./views/dialogs/_GenericFeatureFeedbackDialog.pcss";
|
@import "./views/dialogs/_GenericFeatureFeedbackDialog.pcss";
|
||||||
@import "./views/dialogs/_IncomingSasDialog.pcss";
|
@import "./views/dialogs/_IncomingSasDialog.pcss";
|
||||||
@import "./views/dialogs/_InviteDialog.pcss";
|
@import "./views/dialogs/_InviteDialog.pcss";
|
||||||
|
@import "./views/dialogs/_InviteProgressBody.pcss";
|
||||||
@import "./views/dialogs/_JoinRuleDropdown.pcss";
|
@import "./views/dialogs/_JoinRuleDropdown.pcss";
|
||||||
@import "./views/dialogs/_LeaveSpaceDialog.pcss";
|
@import "./views/dialogs/_LeaveSpaceDialog.pcss";
|
||||||
@import "./views/dialogs/_LocationViewDialog.pcss";
|
@import "./views/dialogs/_LocationViewDialog.pcss";
|
||||||
|
|||||||
@@ -63,17 +63,6 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
height: 25px;
|
height: 25px;
|
||||||
line-height: $font-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 {
|
.mx_InviteDialog_section {
|
||||||
@@ -218,6 +207,10 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
|
.mx_InviteProgressBody {
|
||||||
|
margin-top: var(--cpd-space-12x);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_InviteDialog_transfer {
|
.mx_InviteDialog_transfer {
|
||||||
|
|||||||
16
res/css/views/dialogs/_InviteProgressBody.pcss
Normal file
16
res/css/views/dialogs/_InviteProgressBody.pcss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,7 +40,6 @@ import Field from "../elements/Field";
|
|||||||
import TabbedView, { Tab, TabLocation } from "../../structures/TabbedView";
|
import TabbedView, { Tab, TabLocation } from "../../structures/TabbedView";
|
||||||
import Dialpad from "../voip/DialPad";
|
import Dialpad from "../voip/DialPad";
|
||||||
import QuestionDialog from "./QuestionDialog";
|
import QuestionDialog from "./QuestionDialog";
|
||||||
import Spinner from "../elements/Spinner";
|
|
||||||
import BaseDialog from "./BaseDialog";
|
import BaseDialog from "./BaseDialog";
|
||||||
import DialPadBackspaceButton from "../elements/DialPadBackspaceButton";
|
import DialPadBackspaceButton from "../elements/DialPadBackspaceButton";
|
||||||
import LegacyCallHandler from "../../../LegacyCallHandler";
|
import LegacyCallHandler from "../../../LegacyCallHandler";
|
||||||
@@ -65,6 +64,7 @@ import { UNKNOWN_PROFILE_ERRORS } from "../../../utils/MultiInviter";
|
|||||||
import AskInviteAnywayDialog, { type UnknownProfiles } from "./AskInviteAnywayDialog";
|
import AskInviteAnywayDialog, { type UnknownProfiles } from "./AskInviteAnywayDialog";
|
||||||
import { SdkContextClass } from "../../../contexts/SDKContext";
|
import { SdkContextClass } from "../../../contexts/SDKContext";
|
||||||
import { type UserProfilesStore } from "../../../stores/UserProfilesStore";
|
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.
|
// we have a number of types defined from the Matrix spec which can't reasonably be altered here.
|
||||||
/* eslint-disable camelcase */
|
/* eslint-disable camelcase */
|
||||||
@@ -329,8 +329,14 @@ interface IInviteDialogState {
|
|||||||
dialPadValue: string;
|
dialPadValue: string;
|
||||||
currentTabId: TabId;
|
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;
|
busy: boolean;
|
||||||
|
|
||||||
|
/** Error from the last attempt to send invites. */
|
||||||
errorText?: string;
|
errorText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -617,7 +623,10 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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)) {
|
if (!this.shouldAbortAfterInviteError(result, room)) {
|
||||||
// handles setting error message too
|
// handles setting error message too
|
||||||
this.props.onFinished(true);
|
this.props.onFinished(true);
|
||||||
@@ -1328,11 +1337,6 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
|
|||||||
* "CallTransfer" one.
|
* "CallTransfer" one.
|
||||||
*/
|
*/
|
||||||
private renderMainTab(): JSX.Element {
|
private renderMainTab(): JSX.Element {
|
||||||
let spinner: JSX.Element | undefined;
|
|
||||||
if (this.state.busy) {
|
|
||||||
spinner = <Spinner w={20} h={20} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
let helpText;
|
let helpText;
|
||||||
let buttonText;
|
let buttonText;
|
||||||
let goButtonFn: (() => Promise<void>) | null = null;
|
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>
|
<p className="mx_InviteDialog_helpText">{helpText}</p>
|
||||||
<div className="mx_InviteDialog_addressBar">
|
<div className="mx_InviteDialog_addressBar">
|
||||||
{this.renderEditor()}
|
{this.renderEditor()}
|
||||||
<div className="mx_InviteDialog_buttonAndSpinner">
|
{goButton}
|
||||||
{goButton}
|
|
||||||
{spinner}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{this.renderSuggestions()}
|
{this.state.busy ? <InviteProgressBody /> : this.renderSuggestions()}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
24
src/components/views/dialogs/InviteProgressBody.tsx
Normal file
24
src/components/views/dialogs/InviteProgressBody.tsx
Normal 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;
|
||||||
38
src/components/views/dialogs/InviteProgressDialog.tsx
Normal file
38
src/components/views/dialogs/InviteProgressDialog.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ import SpaceStore from "../../../stores/spaces/SpaceStore";
|
|||||||
import Modal from "../../../Modal";
|
import Modal from "../../../Modal";
|
||||||
import ManageRestrictedJoinRuleDialog from "../dialogs/ManageRestrictedJoinRuleDialog";
|
import ManageRestrictedJoinRuleDialog from "../dialogs/ManageRestrictedJoinRuleDialog";
|
||||||
import RoomUpgradeWarningDialog, { type IFinishedOpts } from "../dialogs/RoomUpgradeWarningDialog";
|
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 { arrayHasDiff } from "../../../utils/arrays";
|
||||||
import { useLocalEcho } from "../../../hooks/useLocalEcho";
|
import { useLocalEcho } from "../../../hooks/useLocalEcho";
|
||||||
import dis from "../../../dispatcher/dispatcher";
|
import dis from "../../../dispatcher/dispatcher";
|
||||||
@@ -120,7 +120,7 @@ const JoinRuleSettings: React.FC<JoinRuleSettingsProps> = ({
|
|||||||
opts: IFinishedOpts,
|
opts: IFinishedOpts,
|
||||||
fn: (progressText: string, progress: number, total: number) => void,
|
fn: (progressText: string, progress: number, total: number) => void,
|
||||||
): Promise<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;
|
const total = 2 + progress.updateSpacesTotal + progress.inviteUsersTotal;
|
||||||
if (!progress.roomUpgraded) {
|
if (!progress.roomUpgraded) {
|
||||||
fn(_t("room_settings|security|join_rule_upgrade_upgrading_room"), 0, total);
|
fn(_t("room_settings|security|join_rule_upgrade_upgrading_room"), 0, total);
|
||||||
@@ -151,7 +151,20 @@ const JoinRuleSettings: React.FC<JoinRuleSettingsProps> = ({
|
|||||||
total,
|
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?.();
|
closeSettingsFn?.();
|
||||||
|
|
||||||
|
|||||||
@@ -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_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_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>.",
|
"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",
|
"recents_section": "Recent Conversations",
|
||||||
"room_failed_partial": "We sent the others, but the below people couldn't be invited to <RoomName/>",
|
"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",
|
"room_failed_partial_title": "Some invites couldn't be sent",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import Modal from "../Modal";
|
|||||||
import SettingsStore from "../settings/SettingsStore";
|
import SettingsStore from "../settings/SettingsStore";
|
||||||
import AskInviteAnywayDialog from "../components/views/dialogs/AskInviteAnywayDialog";
|
import AskInviteAnywayDialog from "../components/views/dialogs/AskInviteAnywayDialog";
|
||||||
import ConfirmUserActionDialog from "../components/views/dialogs/ConfirmUserActionDialog";
|
import ConfirmUserActionDialog from "../components/views/dialogs/ConfirmUserActionDialog";
|
||||||
|
import { openInviteProgressDialog } from "../components/views/dialogs/InviteProgressDialog.tsx";
|
||||||
|
|
||||||
export enum InviteState {
|
export enum InviteState {
|
||||||
Invited = "invited",
|
Invited = "invited",
|
||||||
@@ -44,6 +45,12 @@ const USER_BANNED = "IO.ELEMENT.BANNED";
|
|||||||
export interface MultiInviterOptions {
|
export interface MultiInviterOptions {
|
||||||
/** Optional callback, fired after each invite */
|
/** Optional callback, fired after each invite */
|
||||||
progressCallback?: () => void;
|
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.addresses.push(...addresses);
|
||||||
this.reason = reason;
|
this.reason = reason;
|
||||||
|
|
||||||
for (const addr of this.addresses) {
|
let closeDialog: (() => void) | undefined;
|
||||||
if (getAddressType(addr) === null) {
|
if (!this.options.inhibitProgressDialog) {
|
||||||
this.completionStates[addr] = InviteState.Error;
|
closeDialog = openInviteProgressDialog();
|
||||||
this.errors[addr] = {
|
|
||||||
errcode: "M_INVALID",
|
|
||||||
errorText: _t("invite|invalid_address"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const addr of this.addresses) {
|
try {
|
||||||
// don't try to invite it if it's an invalid address
|
for (const addr of this.addresses) {
|
||||||
// (it will already be marked as an error though,
|
if (getAddressType(addr) === null) {
|
||||||
// so no need to do so again)
|
this.completionStates[addr] = InviteState.Error;
|
||||||
if (getAddressType(addr) === null) {
|
this.errors[addr] = {
|
||||||
continue;
|
errcode: "M_INVALID",
|
||||||
|
errorText: _t("invite|invalid_address"),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// don't re-invite (there's no way in the UI to do this, but
|
for (const addr of this.addresses) {
|
||||||
// for sanity's sake)
|
// don't try to invite it if it's an invalid address
|
||||||
if (this.completionStates[addr] === InviteState.Invited) {
|
// (it will already be marked as an error though,
|
||||||
continue;
|
// 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) {
|
if (unknownProfileUsers.length > 0) {
|
||||||
// `doInvite` suffered a fatal error. The error should have been recorded in `errors`; it's up
|
await this.handleUnknownProfileUsers(unknownProfileUsers);
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
// Remember to close the progress dialog, if we opened one.
|
||||||
|
closeDialog?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.completionStates;
|
return this.completionStates;
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ import { _t } from "../languageHandler";
|
|||||||
import ErrorDialog from "../components/views/dialogs/ErrorDialog";
|
import ErrorDialog from "../components/views/dialogs/ErrorDialog";
|
||||||
import SpaceStore from "../stores/spaces/SpaceStore";
|
import SpaceStore from "../stores/spaces/SpaceStore";
|
||||||
import Spinner from "../components/views/elements/Spinner";
|
import Spinner from "../components/views/elements/Spinner";
|
||||||
|
import type { MultiInviterOptions } from "./MultiInviter";
|
||||||
|
|
||||||
interface IProgress {
|
export interface RoomUpgradeProgress {
|
||||||
roomUpgraded: boolean;
|
roomUpgraded: boolean;
|
||||||
roomSynced?: boolean;
|
roomSynced?: boolean;
|
||||||
inviteUsersProgress?: number;
|
inviteUsersProgress?: number;
|
||||||
@@ -50,7 +51,8 @@ export async function upgradeRoom(
|
|||||||
handleError = true,
|
handleError = true,
|
||||||
updateSpaces = true,
|
updateSpaces = true,
|
||||||
awaitRoom = false,
|
awaitRoom = false,
|
||||||
progressCallback?: (progress: IProgress) => void,
|
progressCallback?: (progress: RoomUpgradeProgress) => void,
|
||||||
|
inhibitInviteProgressDialog = false,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const cli = room.client;
|
const cli = room.client;
|
||||||
let spinnerModal: IHandle<any> | undefined;
|
let spinnerModal: IHandle<any> | undefined;
|
||||||
@@ -77,7 +79,7 @@ export async function upgradeRoom(
|
|||||||
) as Room[];
|
) as Room[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const progress: IProgress = {
|
const progress: RoomUpgradeProgress = {
|
||||||
roomUpgraded: false,
|
roomUpgraded: false,
|
||||||
roomSynced: awaitRoom || inviteUsers ? false : undefined,
|
roomSynced: awaitRoom || inviteUsers ? false : undefined,
|
||||||
inviteUsersProgress: inviteUsers ? 0 : undefined,
|
inviteUsersProgress: inviteUsers ? 0 : undefined,
|
||||||
@@ -112,9 +114,12 @@ export async function upgradeRoom(
|
|||||||
|
|
||||||
if (toInvite.length > 0) {
|
if (toInvite.length > 0) {
|
||||||
// Errors are handled internally to this function
|
// Errors are handled internally to this function
|
||||||
await inviteUsersToRoom(cli, newRoomId, toInvite, () => {
|
await inviteUsersToRoom(cli, newRoomId, toInvite, {
|
||||||
progress.inviteUsersProgress!++;
|
progressCallback: () => {
|
||||||
progressCallback?.(progress);
|
progress.inviteUsersProgress!++;
|
||||||
|
progressCallback?.(progress);
|
||||||
|
},
|
||||||
|
inhibitProgressDialog: inhibitInviteProgressDialog,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,9 +155,9 @@ async function inviteUsersToRoom(
|
|||||||
client: MatrixClient,
|
client: MatrixClient,
|
||||||
roomId: string,
|
roomId: string,
|
||||||
userIds: string[],
|
userIds: string[],
|
||||||
progressCallback?: () => void,
|
inviteOptions: MultiInviterOptions,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const result = await inviteMultipleToRoom(client, roomId, userIds, { progressCallback });
|
const result = await inviteMultipleToRoom(client, roomId, userIds, inviteOptions);
|
||||||
const room = client.getRoom(roomId)!;
|
const room = client.getRoom(roomId)!;
|
||||||
showAnyInviteErrors(result.states, room, result.inviter);
|
showAnyInviteErrors(result.states, room, result.inviter);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ describe("InviteDialog", () => {
|
|||||||
supportsThreads: jest.fn().mockReturnValue(false),
|
supportsThreads: jest.fn().mockReturnValue(false),
|
||||||
isInitialSyncComplete: jest.fn().mockReturnValue(true),
|
isInitialSyncComplete: jest.fn().mockReturnValue(true),
|
||||||
getClientWellKnown: jest.fn().mockResolvedValue({}),
|
getClientWellKnown: jest.fn().mockResolvedValue({}),
|
||||||
|
invite: jest.fn(),
|
||||||
});
|
});
|
||||||
SdkConfig.put({ validated_server_config: {} as ValidatedServerConfig } as IConfigOptions);
|
SdkConfig.put({ validated_server_config: {} as ValidatedServerConfig } as IConfigOptions);
|
||||||
DMRoomMap.makeShared(mockClient);
|
DMRoomMap.makeShared(mockClient);
|
||||||
@@ -406,6 +407,18 @@ describe("InviteDialog", () => {
|
|||||||
expect(tile).toBeInTheDocument();
|
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", () => {
|
describe("when inviting a user with an unknown profile", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
render(<InviteDialog kind={InviteKind.Dm} onFinished={jest.fn()} />);
|
render(<InviteDialog kind={InviteKind.Dm} onFinished={jest.fn()} />);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
`;
|
||||||
@@ -15,7 +15,7 @@ import Modal, { type ComponentType, type ComponentProps } from "../../../src/Mod
|
|||||||
import SettingsStore from "../../../src/settings/SettingsStore";
|
import SettingsStore from "../../../src/settings/SettingsStore";
|
||||||
import MultiInviter, { type CompletionStates } from "../../../src/utils/MultiInviter";
|
import MultiInviter, { type CompletionStates } from "../../../src/utils/MultiInviter";
|
||||||
import * as TestUtilsMatrix from "../../test-utils";
|
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";
|
import ConfirmUserActionDialog from "../../../src/components/views/dialogs/ConfirmUserActionDialog";
|
||||||
|
|
||||||
const ROOMID = "!room:server";
|
const ROOMID = "!room:server";
|
||||||
@@ -24,10 +24,14 @@ const MXID1 = "@user1:server";
|
|||||||
const MXID2 = "@user2:server";
|
const MXID2 = "@user2:server";
|
||||||
const MXID3 = "@user3:server";
|
const MXID3 = "@user3:server";
|
||||||
|
|
||||||
const MXID_PROFILE_STATES: Record<string, Promise<any>> = {
|
const MXID_PROFILE_STATES: Record<string, () => {}> = {
|
||||||
[MXID1]: Promise.resolve({}),
|
[MXID1]: () => ({}),
|
||||||
[MXID2]: Promise.reject(new MatrixError({ errcode: "M_FORBIDDEN" })),
|
[MXID2]: () => {
|
||||||
[MXID3]: Promise.reject(new MatrixError({ errcode: "M_NOT_FOUND" })),
|
throw new MatrixError({ errcode: "M_FORBIDDEN" });
|
||||||
|
},
|
||||||
|
[MXID3]: () => {
|
||||||
|
throw new MatrixError({ errcode: "M_NOT_FOUND" });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
jest.mock("../../../src/Modal", () => ({
|
jest.mock("../../../src/Modal", () => ({
|
||||||
@@ -51,11 +55,12 @@ const mockPromptBeforeInviteUnknownUsers = (value: boolean) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const mockCreateTrackedDialog = (callbackName: "onInviteAnyways" | "onGiveUp") => {
|
const mockCreateTrackedDialog = (callbackName: "onInviteAnyways" | "onGiveUp") => {
|
||||||
mocked(Modal.createDialog).mockImplementation(
|
mocked(Modal.createDialog).mockImplementation((Element: ComponentType, props?: ComponentProps<ComponentType>) => {
|
||||||
(Element: ComponentType, props?: ComponentProps<ComponentType>): any => {
|
if (Element === AskInviteAnywayDialog) {
|
||||||
(props as ComponentProps<typeof AskInviteAnywayDialog>)[callbackName]();
|
(props as ComponentProps<typeof AskInviteAnywayDialog>)[callbackName]();
|
||||||
},
|
}
|
||||||
);
|
return { close: jest.fn(), finished: new Promise(() => {}) };
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const expectAllInvitedResult = (result: CompletionStates) => {
|
const expectAllInvitedResult = (result: CompletionStates) => {
|
||||||
@@ -72,6 +77,7 @@ describe("MultiInviter", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.resetAllMocks();
|
jest.resetAllMocks();
|
||||||
|
mocked(Modal.createDialog).mockReturnValue({ close: jest.fn(), finished: new Promise(() => {}) });
|
||||||
|
|
||||||
TestUtilsMatrix.stubClient();
|
TestUtilsMatrix.stubClient();
|
||||||
client = MatrixClientPeg.safeGet() as jest.Mocked<MatrixClient>;
|
client = MatrixClientPeg.safeGet() as jest.Mocked<MatrixClient>;
|
||||||
@@ -80,8 +86,10 @@ describe("MultiInviter", () => {
|
|||||||
client.invite.mockResolvedValue({});
|
client.invite.mockResolvedValue({});
|
||||||
|
|
||||||
client.getProfileInfo = jest.fn();
|
client.getProfileInfo = jest.fn();
|
||||||
client.getProfileInfo.mockImplementation((userId: string) => {
|
client.getProfileInfo.mockImplementation(async (userId: string) => {
|
||||||
return MXID_PROFILE_STATES[userId] || Promise.reject();
|
const m = MXID_PROFILE_STATES[userId];
|
||||||
|
if (m) return m();
|
||||||
|
throw new Error();
|
||||||
});
|
});
|
||||||
client.unban = jest.fn();
|
client.unban = jest.fn();
|
||||||
|
|
||||||
@@ -89,6 +97,22 @@ describe("MultiInviter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("invite", () => {
|
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", () => {
|
describe("with promptBeforeInviteUnknownUsers = false", () => {
|
||||||
beforeEach(() => mockPromptBeforeInviteUnknownUsers(false));
|
beforeEach(() => mockPromptBeforeInviteUnknownUsers(false));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user