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 20:05:31 +00:00
committed by GitHub
parent f4c62abbcd
commit cd515444a8
18 changed files with 511 additions and 27 deletions
@@ -61,6 +61,9 @@ import { type UserProfilesStore } from "../../../stores/UserProfilesStore";
import InviteProgressBody from "./InviteProgressBody.tsx";
import MultiInviter, { type CompletionStates as MultiInviterCompletionStates } from "../../../utils/MultiInviter.ts";
import { DMRoomTile } from "./invite/DMRoomTile.tsx";
import { logErrorAndShowErrorDialog } from "../../../utils/ErrorUtils.tsx";
import UnknownIdentityUsersWarningDialog from "./invite/UnknownIdentityUsersWarningDialog.tsx";
import { AddressType, getAddressType } from "../../../UserAddress.ts";
interface Result {
userId: string;
@@ -161,6 +164,14 @@ interface IInviteDialogState {
dialPadValue: string;
currentTabId: TabId;
/**
* If we tried to invite some users whose identity we don't know, we will show a warning.
* This is the list of users. (If it is `null`, we are not showing that warning.)
*
* Will never be the empty list.
*/
unknownIdentityUsers: Member[] | null;
/**
* True if we are sending the invites.
*
@@ -230,7 +241,8 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
dialPadValue: "",
currentTabId: TabId.UserDirectory,
// These two flags are used for the 'Go' button to communicate what is going on.
unknownIdentityUsers: null,
busy: false,
};
}
@@ -444,6 +456,21 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
}
};
/**
* Start the process of actually sending invites or creating a DM.
*
* Called once we have shown the user all the necessary warnings.
*/
private async startDmOrSendInvites(): Promise<void> {
if (this.props.kind === InviteKind.Dm) {
await this.startDm();
} else if (this.props.kind === InviteKind.Invite) {
await this.inviteUsers();
} else {
throw new Error("Unknown InviteKind: " + this.props.kind);
}
}
private transferCall = async (): Promise<void> => {
if (this.props.kind !== InviteKind.CallTransfer) return;
if (this.state.currentTabId == TabId.UserDirectory) {
@@ -1123,14 +1150,49 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
);
}
/**
* Handle the user pressing the Go/Invite button in the "Start Chat" or "Invite users" view.
*
* We check if any of the users lack a known cryptographic identity, and show a warning if so.
*/
private async onGoButtonPressed(): Promise<void> {
this.setBusy(true);
const targets = this.convertFilter();
const unknownIdentityUsers: Member[] = [];
const cli = MatrixClientPeg.safeGet();
const crypto = cli.getCrypto();
if (crypto) {
for (const t of targets) {
const addressType = getAddressType(t.userId);
if (
addressType !== AddressType.MatrixUserId ||
!(await crypto.getUserVerificationStatus(t.userId)).known
) {
unknownIdentityUsers.push(t);
}
}
}
// If we have some users with unknown identities, show the warning page.
if (unknownIdentityUsers.length > 0) {
logger.debug(
"InviteDialog: Warning about users with unknown identities:",
unknownIdentityUsers.map((u) => u.userId),
);
this.setState({ unknownIdentityUsers: unknownIdentityUsers, busy: false });
} else {
// Otherwise, transition directly to sending the relevant invites.
await this.startDmOrSendInvites();
}
}
/**
* Render content of the "users" that is used for both invites and "start chat".
*/
private renderMainTab(): JSX.Element {
let helpText;
let buttonText;
let goButtonFn: (() => Promise<void>) | null = null;
const identityServersEnabled = SettingsStore.getValue(UIFeature.IdentityServer);
const cli = MatrixClientPeg.safeGet();
@@ -1167,7 +1229,6 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
}
buttonText = _t("action|go");
goButtonFn = this.startDm;
} else if (this.props.kind === InviteKind.Invite) {
const roomId = this.props.roomId;
const room = MatrixClientPeg.get()?.getRoom(roomId);
@@ -1211,11 +1272,14 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
);
buttonText = _t("action|invite");
goButtonFn = this.inviteUsers;
} else {
throw new Error("Unknown InviteDialog kind: " + this.props.kind);
}
const onGoButtonPressed = (): void => {
this.onGoButtonPressed().catch((e) => logErrorAndShowErrorDialog("Error processing invites", e));
};
return (
<React.Fragment>
<p className="mx_InviteDialog_helpText">{helpText}</p>
@@ -1223,7 +1287,7 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
{this.renderEditor()}
<AccessibleButton
kind="primary"
onClick={goButtonFn}
onClick={onGoButtonPressed}
className="mx_InviteDialog_goButton"
disabled={this.state.busy || !this.hasSelection()}
>
@@ -1235,12 +1299,49 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
);
}
/** Callback function, which handles the user clicking "Remove" on the {@link UnknwownIdentityUsersWarningDialog}. */
private onRemoveUnknownIdentityUsersClicked = (): void => {
// Remove the unknown identity users, then return to the previous screen
const newTargets: Member[] = [];
for (const target of this.state.targets) {
if (!this.state.unknownIdentityUsers?.find((m) => m.userId == target.userId)) {
newTargets.push(target);
}
}
this.setState({
targets: newTargets,
unknownIdentityUsers: null,
});
};
/**
* Render the complete dialog, given this is not a call transfer dialog.
*
* See also: {@link renderCallTransferDialog}.
*/
private renderRegularDialog(): React.ReactNode {
if (this.props.kind !== InviteKind.Dm && this.props.kind !== InviteKind.Invite) {
throw new Error("Unsupported InviteDialog kind: " + this.props.kind);
}
if (this.state.unknownIdentityUsers !== null) {
return (
<UnknownIdentityUsersWarningDialog
onCancel={this.props.onFinished}
onContinue={() => {
this.setState({ unknownIdentityUsers: null });
this.startDmOrSendInvites().catch((e) =>
logErrorAndShowErrorDialog("Error processing invites", e),
);
}}
onRemove={this.onRemoveUnknownIdentityUsersClicked}
screenName={this.screenName}
kind={this.props.kind}
users={this.state.unknownIdentityUsers}
/>
);
}
let title;
if (this.props.kind === InviteKind.Dm) {
title = _t("space|add_existing_room_space|dm_heading");
@@ -19,8 +19,8 @@ import { Icon as EmailPillAvatarIcon } from "../../../../../res/img/icon-email-p
interface IDMRoomTileProps {
member: Member;
lastActiveTs?: number;
onToggle(member: Member): void;
isSelected: boolean;
onToggle?(member: Member): void;
isSelected?: boolean;
}
/** A tile representing a single user in the "suggestions"/"recents" section of the invite dialog. */
@@ -30,7 +30,7 @@ export class DMRoomTile extends React.PureComponent<IDMRoomTileProps> {
e.preventDefault();
e.stopPropagation();
this.props.onToggle(this.props.member);
this.props.onToggle?.(this.props.member);
};
public render(): React.ReactNode {
@@ -0,0 +1,121 @@
/*
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 JSX, useCallback } from "react";
import { CheckIcon, CloseIcon, UserAddSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { Button, PageHeader } from "@vector-im/compound-web";
import { InviteKind } from "../InviteDialogTypes.ts";
import { type Member } from "../../../../utils/direct-messages.ts";
import BaseDialog from "../BaseDialog.tsx";
import { type ScreenName } from "../../../../PosthogTrackers.ts";
import { DMRoomTile } from "./DMRoomTile.tsx";
import { _t } from "../../../../languageHandler.tsx";
interface Props {
/** Callback that will be called when the 'Continue' or 'Invite' button is clicked. */
onContinue: () => void;
/** Callback that will be called when the 'Cancel' button is clicked. Unused unless {@link kind} is {@link InviteKind.Dm}. */
onCancel: () => void;
/** Callback that will be called when the 'Remove' button is clicked. Unused unless {@link kind} is {@link InviteKind.Invite}. */
onRemove: () => void;
/** Optional Posthog ScreenName to supply during the lifetime of this dialog. */
screenName: ScreenName | undefined;
/** The type of invite dialog: whether we are starting a new DM, or inviting users to an existing room */
kind: InviteKind.Dm | InviteKind.Invite;
/** The users whose identities we don't know */
users: Member[];
}
/**
* Part of the invite dialog: a screen that appears if there are any users whose cryptographic identity we don't know,
* to confirm that they are the right users.
*
* Figma: https://www.figma.com/design/chAcaQAluTuRg6BsG4Npc0/-3163--Inviting-Unknown-People?node-id=150-17719&t=ISAikbnj97LM4NwT-0
*/
const UnknownIdentityUsersWarningDialog: React.FC<Props> = (props) => {
const userListItem = useCallback((u: Member) => <DMRoomTile member={u} key={u.userId} />, []);
let title: string;
let headerText: string;
let buttons: JSX.Element;
switch (props.kind) {
case InviteKind.Invite:
title = _t("invite|confirm_unknown_users|invite_title");
headerText = _t("invite|confirm_unknown_users|invite_subtitle");
buttons = <InviteButtons onInvite={props.onContinue} onRemove={props.onRemove} />;
break;
case InviteKind.Dm:
title =
props.users.length == 1
? _t("invite|confirm_unknown_users|start_chat_title_one_user")
: _t("invite|confirm_unknown_users|start_chat_title_multiple_users");
headerText =
props.users.length == 1
? _t("invite|confirm_unknown_users|start_chat_subtitle_one_user")
: _t("invite|confirm_unknown_users|start_chat_subtitle_multiple_users");
buttons = <DmButtons onCancel={props.onCancel} onContinue={props.onContinue} />;
break;
}
return (
<BaseDialog
onFinished={props.onCancel}
className="mx_UnknownIdentityUsersWarningDialog"
screenName={props.screenName}
>
<div className="mx_UnknownIdentityUsersWarningDialog_headerContainer">
<PageHeader Icon={UserAddSolidIcon} heading={title}>
<p>{headerText}</p>
</PageHeader>
</div>
<ul className="mx_UnknownIdentityUsersWarningDialog_userList" data-testid="userlist">
{props.users.map(userListItem)}
</ul>
<div className="mx_UnknownIdentityUsersWarningDialog_buttons">{buttons}</div>
</BaseDialog>
);
};
const DmButtons: React.FC<{ onContinue: () => void; onCancel: () => void }> = (props) => {
return (
<>
<Button size="lg" kind="secondary" onClick={props.onCancel}>
{_t("action|cancel")}
</Button>
<Button size="lg" kind="primary" onClick={props.onContinue}>
{_t("action|continue")}
</Button>
</>
);
};
const InviteButtons: React.FC<{ onInvite: () => void; onRemove: () => void }> = (props) => {
return (
<>
<Button size="lg" kind="secondary" onClick={props.onRemove} Icon={CloseIcon}>
{_t("action|remove")}
</Button>
<Button size="lg" kind="primary" onClick={props.onInvite} Icon={CheckIcon}>
{_t("action|invite")}
</Button>
</>
);
};
export default UnknownIdentityUsersWarningDialog;