From 19001fb04c192580c78a66823db523f4ada3d95e Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Thu, 16 Apr 2026 11:09:26 +0100 Subject: [PATCH] Add `UnknownIdentityUsersWarningDialog` --- apps/web/res/css/_common.pcss | 16 ++- apps/web/res/css/_components.pcss | 1 + .../_UnknownIdentityUsersWarningDialog.pcss | 45 ++++++++ .../components/views/dialogs/InviteDialog.tsx | 85 +++++++++++++- .../views/dialogs/invite/DMRoomTile.tsx | 6 +- .../UnknownIdentityUsersWarningDialog.tsx | 105 ++++++++++++++++++ .../views/dialogs/InviteDialog-test.tsx | 6 + 7 files changed, 254 insertions(+), 10 deletions(-) create mode 100644 apps/web/res/css/views/dialogs/invite/_UnknownIdentityUsersWarningDialog.pcss create mode 100644 apps/web/src/components/views/dialogs/invite/UnknownIdentityUsersWarningDialog.tsx diff --git a/apps/web/res/css/_common.pcss b/apps/web/res/css/_common.pcss index f3a9fdcb94..d55cb07606 100644 --- a/apps/web/res/css/_common.pcss +++ b/apps/web/res/css/_common.pcss @@ -598,6 +598,7 @@ legend { .mx_AccessSecretStorageDialog button, .mx_InviteDialog_section button, .mx_InviteDialog_editor button, + .mx_UnknownIdentityUsersWarningDialog button, [class|="maplibregl"] ), .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton), @@ -625,7 +626,8 @@ legend { .mx_ThemeChoicePanel_CustomTheme button, .mx_UnpinAllDialog button, .mx_ShareDialog button, - .mx_EncryptionUserSettingsTab button + .mx_EncryptionUserSettingsTab button, + .mx_UnknownIdentityUsersWarningDialog button ):last-child { margin-right: 0px; } @@ -641,7 +643,8 @@ legend { .mx_ShareDialog button, .mx_EncryptionUserSettingsTab button, .mx_InviteDialog_section button, - .mx_InviteDialog_editor button + .mx_InviteDialog_editor button, + .mx_UnknownIdentityUsersWarningDialog button ):focus, .mx_Dialog input[type="submit"]:focus, .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton):focus, @@ -659,7 +662,8 @@ legend { .mx_ThemeChoicePanel_CustomTheme button, .mx_UnpinAllDialog button, .mx_ShareDialog button, - .mx_EncryptionUserSettingsTab button + .mx_EncryptionUserSettingsTab button, + .mx_UnknownIdentityUsersWarningDialog button ), .mx_Dialog_buttons input[type="submit"].mx_Dialog_primary { color: var(--cpd-color-text-on-solid-primary); @@ -678,7 +682,8 @@ legend { .mx_ThemeChoicePanel_CustomTheme button, .mx_UnpinAllDialog button, .mx_ShareDialog button, - .mx_EncryptionUserSettingsTab button + .mx_EncryptionUserSettingsTab button, + .mx_UnknownIdentityUsersWarningDialog button ), .mx_Dialog_buttons input[type="submit"].danger { background-color: var(--cpd-color-bg-critical-primary); @@ -701,7 +706,8 @@ legend { .mx_ThemeChoicePanel_CustomTheme button, .mx_UnpinAllDialog button, .mx_ShareDialog button, - .mx_EncryptionUserSettingsTab button + .mx_EncryptionUserSettingsTab button, + .mx_UnknownIdentityUsersWarningDialog button ):disabled, .mx_Dialog input[type="submit"]:disabled, .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton):disabled, diff --git a/apps/web/res/css/_components.pcss b/apps/web/res/css/_components.pcss index c208a71ef7..6bee80f17d 100644 --- a/apps/web/res/css/_components.pcss +++ b/apps/web/res/css/_components.pcss @@ -170,6 +170,7 @@ @import "./views/dialogs/_UserSettingsDialog.pcss"; @import "./views/dialogs/_VerifyEMailDialog.pcss"; @import "./views/dialogs/_WidgetCapabilitiesPromptDialog.pcss"; +@import "./views/dialogs/invite/_UnknownIdentityUsersWarningDialog.pcss"; @import "./views/dialogs/security/_AccessSecretStorageDialog.pcss"; @import "./views/dialogs/security/_CreateCrossSigningDialog.pcss"; @import "./views/dialogs/security/_CreateSecretStorageDialog.pcss"; diff --git a/apps/web/res/css/views/dialogs/invite/_UnknownIdentityUsersWarningDialog.pcss b/apps/web/res/css/views/dialogs/invite/_UnknownIdentityUsersWarningDialog.pcss new file mode 100644 index 0000000000..085d9dfa5d --- /dev/null +++ b/apps/web/res/css/views/dialogs/invite/_UnknownIdentityUsersWarningDialog.pcss @@ -0,0 +1,45 @@ +/* + 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. +*/ + +.mx_UnknownIdentityUsersWarningDialog { + display: flex; + flex-direction: column; + height: 600px; /* Consistency with InviteDialog */ +} + +.mx_UnknownIdentityUsersWarningDialog_headerContainer { + /* Centre the PageHeader component horizontally */ + display: flex; + justify-content: center; + + /* Styling for the regular text inside the header */ + font: var(--cpd-font-body-lg-regular); + + /* Space before the list */ + padding-bottom: var(--cpd-space-6x); +} + +.mx_UnknownIdentityUsersWarningDialog_userList { + width: 100%; + overflow: auto; + + /* Fill available vertical space, but don't allow it to shrink to less than 60px (about the height of a single tile) */ + flex: 1 0 60px; + + /* Remove browser default ul padding/margin */ + padding: 0; + margin: 0; +} + +.mx_UnknownIdentityUsersWarningDialog_buttons { + display: flex; + gap: var(--cpd-space-4x); + + > button { + flex: 1; + } +} diff --git a/apps/web/src/components/views/dialogs/InviteDialog.tsx b/apps/web/src/components/views/dialogs/InviteDialog.tsx index 9817a12403..f87378aa89 100644 --- a/apps/web/src/components/views/dialogs/InviteDialog.tsx +++ b/apps/web/src/components/views/dialogs/InviteDialog.tsx @@ -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,12 @@ 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.) + */ + unknownIdentityUsers: Member[] | null; + /** * True if we are sending the invites. * @@ -230,7 +239,8 @@ export default class InviteDialog extends React.PureComponent { + 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". */ @@ -1228,7 +1275,7 @@ export default class InviteDialog extends React.PureComponent { - this.startDmOrSendInvites().catch((e) => logErrorAndShowErrorDialog("Error processing invites", e)); + this.onGoButtonPressed().catch((e) => logErrorAndShowErrorDialog("Error processing invites", e)); }; return ( @@ -1256,6 +1303,40 @@ export default class InviteDialog extends React.PureComponent { + this.setState({ unknownIdentityUsers: null }); + this.startDmOrSendInvites().catch((e) => + logErrorAndShowErrorDialog("Error processing invites", e), + ); + }} + onRemove={() => { + // 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, + }); + }} + 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"); diff --git a/apps/web/src/components/views/dialogs/invite/DMRoomTile.tsx b/apps/web/src/components/views/dialogs/invite/DMRoomTile.tsx index 954e792b17..8998977cf2 100644 --- a/apps/web/src/components/views/dialogs/invite/DMRoomTile.tsx +++ b/apps/web/src/components/views/dialogs/invite/DMRoomTile.tsx @@ -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 { e.preventDefault(); e.stopPropagation(); - this.props.onToggle(this.props.member); + this.props.onToggle?.(this.props.member); }; public render(): React.ReactNode { diff --git a/apps/web/src/components/views/dialogs/invite/UnknownIdentityUsersWarningDialog.tsx b/apps/web/src/components/views/dialogs/invite/UnknownIdentityUsersWarningDialog.tsx new file mode 100644 index 0000000000..aa06b9ab1f --- /dev/null +++ b/apps/web/src/components/views/dialogs/invite/UnknownIdentityUsersWarningDialog.tsx @@ -0,0 +1,105 @@ +/* + 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"; + +interface Props { + /** Callback that will be called when the 'Continue' button is clicked. */ + onContinue: () => void; + + /** Callback that will be called when the 'close' or 'Cancel' button is clicked or 'Escape' is pressed. */ + onCancel: () => void; + + /** Callback that will be called when the 'Remove' button is clicked. */ + 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[]; +} + +/** + * + * Figma: https://www.figma.com/design/chAcaQAluTuRg6BsG4Npc0/-3163--Inviting-Unknown-People?node-id=150-17719&t=ISAikbnj97LM4NwT-0 + */ +export default function UnknownIdentityUsersWarningDialog(props: Props): JSX.Element { + const userListItem = useCallback((u: Member) => , []); + + // TODO i18n, plurals, different wording for invites + const title = "Start a chat with these new contacts?"; + const headerText = "You currently don't have any chats with these people. Confirm inviting them before continuing."; + + const buttons = + props.kind == InviteKind.Invite + ? inviteButtons({ + onInvite: props.onContinue, + onRemove: props.onRemove, + }) + : dmButtons({ + onCancel: props.onCancel, + onContinue: props.onContinue, + }); + + return ( + +
+ +

{headerText}

+
+
+ +
    + {props.users.map(userListItem)} +
+ +
{buttons}
+
+ ); +} + +function dmButtons(props: { onContinue: () => void; onCancel: () => void }): JSX.Element { + return ( + <> + + + + ); +} + +function inviteButtons(props: { onInvite: () => void; onRemove: () => void }): JSX.Element { + return ( + <> + + + + ); +} diff --git a/apps/web/test/unit-tests/components/views/dialogs/InviteDialog-test.tsx b/apps/web/test/unit-tests/components/views/dialogs/InviteDialog-test.tsx index 3f9eb6ac5c..7d188fcca9 100644 --- a/apps/web/test/unit-tests/components/views/dialogs/InviteDialog-test.tsx +++ b/apps/web/test/unit-tests/components/views/dialogs/InviteDialog-test.tsx @@ -13,6 +13,7 @@ import { RoomType, type MatrixClient, MatrixError, Room } from "matrix-js-sdk/sr 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),