Confirm before inviting unknown users to a DM/room (#33171)

* InviteDialog: factor out startDmOrSendInvites

Factor out the logic of calling `startDm` or `inviteUsers` to a helper
function. We're going to need to call this from a second location soon, so this
is useful groundwork.

* Add `UnknownIdentityUsersWarningDialog`

* Add unit tests

* Update playwright tests

* Convert if/else to switch statement

* Convert helper functions to React components

* Factor out "onRemove" callback

* Add clarifying comment
This commit is contained in:
Richard van der Hoff
2026-04-22 21:05:31 +01:00
committed by GitHub
parent f4c62abbcd
commit cd515444a8
18 changed files with 511 additions and 27 deletions
@@ -7,12 +7,13 @@ Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { fireEvent, render, screen, findByText } from "jest-matrix-react";
import { findByText, fireEvent, render, screen } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import { RoomType, type MatrixClient, MatrixError, Room } from "matrix-js-sdk/src/matrix";
import { type MatrixClient, MatrixError, Room, RoomType } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { sleep } from "matrix-js-sdk/src/utils";
import { mocked, type Mocked } from "jest-mock";
import { UserVerificationStatus } from "matrix-js-sdk/src/crypto-api";
import InviteDialog from "../../../../../src/components/views/dialogs/InviteDialog";
import { InviteKind } from "../../../../../src/components/views/dialogs/InviteDialogTypes";
@@ -103,6 +104,11 @@ describe("InviteDialog", () => {
beforeEach(() => {
mockClient = getMockClientWithEventEmitter({
getCrypto: jest.fn().mockReturnValue({
getUserVerificationStatus: jest
.fn()
.mockResolvedValue(new UserVerificationStatus(false, false, true, false)),
}),
getDomain: jest.fn().mockReturnValue(serverDomain),
getUserId: jest.fn().mockReturnValue(bobId),
getSafeUserId: jest.fn().mockReturnValue(bobId),
@@ -449,4 +455,44 @@ describe("InviteDialog", () => {
await flushPromises();
expect(screen.queryByText("@localpart:server.tld")).not.toBeInTheDocument();
});
describe("when inviting a user whose cryptographic identity we do not know", () => {
beforeEach(() => {
mocked(mockClient.getCrypto()!.getUserVerificationStatus).mockImplementation(async (u) => {
return new UserVerificationStatus(false, false, false, false);
});
});
describe.each([InviteKind.Invite, InviteKind.Dm])("with invitekind '%s'", (kind) => {
const goButtonName = kind == InviteKind.Invite ? "Invite" : "Go";
beforeEach(() => {
render(
<InviteDialog
kind={kind as InviteKind.Invite | InviteKind.Dm}
roomId={roomId}
onFinished={jest.fn()}
/>,
);
});
it("should show a warning when inviting by user id", async () => {
await enterIntoSearchField(aliceId);
await userEvent.click(screen.getByRole("button", { name: goButtonName }));
await screen.findByText("Confirm inviting them", { exact: false });
expect(mocked(mockClient.getCrypto()!.getUserVerificationStatus)).toHaveBeenCalledTimes(1);
expect(mocked(mockClient.getCrypto()!.getUserVerificationStatus)).toHaveBeenCalledWith(aliceId);
});
it("should show a warning when inviting by email address", async () => {
await enterIntoSearchField("aaa@bbb");
await userEvent.click(screen.getByRole("button", { name: goButtonName }));
await screen.findByText("Confirm inviting them", { exact: false });
// We shouldn't call getUserVerificationStatus on an email address
expect(mocked(mockClient.getCrypto()!.getUserVerificationStatus)).not.toHaveBeenCalled();
});
});
});
});
@@ -0,0 +1,104 @@
/*
Copyright 2026 Element Creations 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, { type ComponentProps } from "react";
import { render, type RenderResult } from "jest-matrix-react";
import { getAllByRole, getAllByText, getByText } from "@testing-library/dom";
import UnknownIdentityUsersWarningDialog from "../../../../../../src/components/views/dialogs/invite/UnknownIdentityUsersWarningDialog.tsx";
import { InviteKind } from "../../../../../../src/components/views/dialogs/InviteDialogTypes.ts";
import { DirectoryMember, ThreepidMember } from "../../../../../../src/utils/direct-messages.ts";
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../../../../test-utils";
describe("UnknownIdentityUsersWarningDialog", () => {
beforeEach(() => {
getMockClientWithEventEmitter({
...mockClientMethodsUser(),
});
});
afterEach(() => {
jest.restoreAllMocks();
});
it("should show entries for each user", () => {
const result = renderComponent({
users: [
new DirectoryMember({ user_id: "@alice:example.com" }),
new DirectoryMember({
user_id: "@bob:example.net",
display_name: "Bob",
avatar_url: "mxc://example.com/abc",
}),
new ThreepidMember("charlie@example.com"),
],
});
const list = result.getByTestId("userlist");
const entries = getAllByRole(list, "option");
expect(entries).toHaveLength(3);
// No displayname so mxid is displayed twice
expect(getAllByText(entries[0], "@alice:example.com")).toHaveLength(2);
getByText(entries[1], "Bob");
getByText(entries[2], "charlie@example.com");
});
describe("in DM mode", () => {
const kind = InviteKind.Dm;
it("shows a 'Continue' button", () => {
const onContinue = jest.fn();
const result = renderComponent({ kind, onContinue });
const continueButton = result.getByRole("button", { name: "Continue" });
continueButton.click();
expect(onContinue).toHaveBeenCalled();
});
it("shows a 'Cancel' button", () => {
const onCancel = jest.fn();
const result = renderComponent({ kind, onCancel });
const cancelButton = result.getByRole("button", { name: "Cancel" });
cancelButton.click();
expect(onCancel).toHaveBeenCalled();
});
});
describe("in Invite mode", () => {
const kind = InviteKind.Invite;
it("shows an 'Invite' button", () => {
const onContinue = jest.fn();
const result = renderComponent({ kind, onContinue });
const continueButton = result.getByRole("button", { name: "Invite" });
continueButton.click();
expect(onContinue).toHaveBeenCalled();
});
it("shows a 'Remove' button", () => {
const onRemove = jest.fn();
const result = renderComponent({ kind, onRemove });
const removeButton = result.getByRole("button", { name: "Remove" });
removeButton.click();
expect(onRemove).toHaveBeenCalled();
});
});
});
function renderComponent(props: Partial<ComponentProps<typeof UnknownIdentityUsersWarningDialog>>): RenderResult {
const props1: ComponentProps<typeof UnknownIdentityUsersWarningDialog> = {
onContinue: () => {},
onCancel: () => {},
onRemove: () => {},
screenName: undefined,
kind: InviteKind.Dm,
users: [],
...props,
};
return render(<UnknownIdentityUsersWarningDialog {...props1} />);
}