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