diff --git a/apps/web/playwright/e2e/crypto/crypto.spec.ts b/apps/web/playwright/e2e/crypto/crypto.spec.ts index 9a4c6b09092..a5dfd32755d 100644 --- a/apps/web/playwright/e2e/crypto/crypto.spec.ts +++ b/apps/web/playwright/e2e/crypto/crypto.spec.ts @@ -27,6 +27,9 @@ const startDMWithBob = async (page: Page, bob: Bot) => { await page.getByRole("option", { name: bob.credentials.displayName }).click(); await expect(page.getByTestId("invite-dialog-input-wrapper").getByText("Bob")).toBeVisible(); await page.getByRole("button", { name: "Go" }).click(); + + await expect(page.getByRole("heading", { name: "Start a chat with this new contact?" })).toBeVisible(); + await page.getByRole("button", { name: "Continue" }).click(); }; const testMessages = async (page: Page, bob: Bot, bobRoomId: string) => { diff --git a/apps/web/playwright/e2e/crypto/history-sharing.spec.ts b/apps/web/playwright/e2e/crypto/history-sharing.spec.ts index 3974ba79c5f..29676036ba4 100644 --- a/apps/web/playwright/e2e/crypto/history-sharing.spec.ts +++ b/apps/web/playwright/e2e/crypto/history-sharing.spec.ts @@ -49,7 +49,7 @@ test.describe("History sharing", function () { await sendMessageInCurrentRoom(alicePage, "A message from Alice"); // Send the invite to Bob - await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId); + await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId, { confirmUnknownUser: true }); // Bob accepts the invite await bobPage.getByRole("option", { name: "TestRoom" }).click(); @@ -105,7 +105,7 @@ test.describe("History sharing", function () { // Alice invites Bob, and Bob accepts const roomId = await aliceElementApp.getCurrentRoomIdFromUrl(); - await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId); + await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId, { confirmUnknownUser: true }); await bobPage.getByRole("option", { name: "TestRoom" }).click(); await bobPage.getByRole("button", { name: "Accept" }).click(); @@ -143,7 +143,7 @@ test.describe("History sharing", function () { await sendMessageInCurrentRoom(bobPage, "Message3: 'shared' visibility, but Bob thinks it is still 'joined'"); // Alice now invites Charlie - await aliceElementApp.inviteUserToCurrentRoom(charlieCredentials.userId); + await aliceElementApp.inviteUserToCurrentRoom(charlieCredentials.userId, { confirmUnknownUser: true }); await charliePage.getByRole("option", { name: "TestRoom" }).click(); await charliePage.getByRole("button", { name: "Accept" }).click(); diff --git a/apps/web/playwright/e2e/invite/invite-dialog.spec.ts b/apps/web/playwright/e2e/invite/invite-dialog.spec.ts index 42588f37c7b..60fb6bf4afa 100644 --- a/apps/web/playwright/e2e/invite/invite-dialog.spec.ts +++ b/apps/web/playwright/e2e/invite/invite-dialog.spec.ts @@ -9,6 +9,15 @@ Please see LICENSE files in the repository root for full details. import { test, expect } from "../../element-web-test"; +/** + * CSS which will hide the mxid in the user list of the "unknown users" confirmation dialog. This is useful because the + * MXID is not stable and the screenshot tests will otherwise fail. + * + * Ideally RichItem would give us a way to do this that doesn't involve gnarly CSS. + */ +const UNKNOWN_IDENTITY_USERS_DIALOG_HIDE_MXID_CSS = + '[data-testid="userlist"] li > span:nth-last-child(1) { display: none }'; + test.describe("Invite dialog", function () { test.use({ displayName: "Hanako", @@ -62,6 +71,15 @@ test.describe("Invite dialog", function () { // Invite the bot await other.getByRole("button", { name: "Invite" }).click(); + // Expect a confirmation dialog, screenshot, and dismiss + await expect( + page.locator(".mx_Dialog").getByRole("heading", { name: "Invite new contacts to this room?" }), + ).toBeVisible(); + await expect(page.locator(".mx_Dialog")).toMatchScreenshot("confirm-invite-new-contact.png", { + css: UNKNOWN_IDENTITY_USERS_DIALOG_HIDE_MXID_CSS, + }); + await page.locator(".mx_Dialog").getByRole("button", { name: "Invite" }).click(); + // Assert that the invite dialog disappears await expect(page.locator(".mx_InviteDialog_other")).not.toBeVisible(); @@ -104,6 +122,15 @@ test.describe("Invite dialog", function () { // Open a direct message UI await other.getByRole("button", { name: "Go" }).click(); + // Expect a confirmation dialog, screenshot, and dismiss + await expect( + page.locator(".mx_Dialog").getByRole("heading", { name: "Start a chat with this new contact?" }), + ).toBeVisible(); + await expect(page.locator(".mx_Dialog")).toMatchScreenshot("confirm-chat-with-new-contact.png", { + css: UNKNOWN_IDENTITY_USERS_DIALOG_HIDE_MXID_CSS, + }); + await page.locator(".mx_Dialog").getByRole("button", { name: "Continue" }).click(); + // Assert that the invite dialog disappears await expect(page.locator(".mx_InviteDialog_other")).not.toBeVisible(); diff --git a/apps/web/playwright/e2e/room/create-room.spec.ts b/apps/web/playwright/e2e/room/create-room.spec.ts index 554f972c7d5..fd699dcec87 100644 --- a/apps/web/playwright/e2e/room/create-room.spec.ts +++ b/apps/web/playwright/e2e/room/create-room.spec.ts @@ -57,6 +57,9 @@ test.describe("Create Room", () => { await page.getByRole("button", { name: "Go" }).click(); + await expect(page.getByRole("heading", { name: "Start a chat with this new contact?" })).toBeVisible(); + await page.getByRole("button", { name: "Continue" }).click(); + await expect(page.getByText("Encryption enabled")).toBeVisible(); await expect(page.getByText("Send your first message to")).toBeVisible(); diff --git a/apps/web/playwright/e2e/room/room-status-bar.spec.ts b/apps/web/playwright/e2e/room/room-status-bar.spec.ts index 78d5c49a300..dc11333753b 100644 --- a/apps/web/playwright/e2e/room/room-status-bar.spec.ts +++ b/apps/web/playwright/e2e/room/room-status-bar.spec.ts @@ -163,6 +163,10 @@ test.describe("Room Status Bar", () => { ).toBeVisible(); await other.getByRole("option", { name: "Alice" }).click(); await other.getByRole("button", { name: "Go" }).click(); + + await expect(page.getByRole("heading", { name: "Start a chat with this new contact?" })).toBeVisible(); + await page.getByRole("button", { name: "Continue" }).click(); + // Send a message to invite the bots const composer = app.getComposerField(); await composer.fill("Hello"); diff --git a/apps/web/playwright/e2e/settings/encryption-user-tab/other-devices.spec.ts b/apps/web/playwright/e2e/settings/encryption-user-tab/other-devices.spec.ts index 6c20af2d9a5..a46e6eef368 100644 --- a/apps/web/playwright/e2e/settings/encryption-user-tab/other-devices.spec.ts +++ b/apps/web/playwright/e2e/settings/encryption-user-tab/other-devices.spec.ts @@ -33,7 +33,7 @@ test.describe("Other people's devices section in Encryption tab", () => { // Create the room and invite bob await createRoom(alicePage, "TestRoom", true); - await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId); + await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId, { confirmUnknownUser: true }); // Bob accepts the invite await bobPage.getByRole("option", { name: "TestRoom" }).click(); @@ -72,7 +72,7 @@ test.describe("Other people's devices section in Encryption tab", () => { // Create the room and invite bob await createRoom(alicePage, "TestRoom", true); - await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId); + await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId, { confirmUnknownUser: true }); // Bob accepts the invite await bobPage.getByRole("option", { name: "TestRoom" }).click(); @@ -115,7 +115,7 @@ test.describe("Other people's devices section in Encryption tab", () => { // Create the room and invite bob await createRoom(alicePage, "TestRoom", true); - await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId); + await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId, { confirmUnknownUser: true }); // Bob accepts the invite and dismisses the warnings. await bobPage.getByRole("option", { name: "TestRoom" }).click(); @@ -149,7 +149,7 @@ test.describe("Other people's devices section in Encryption tab", () => { // Alice creates the room and invite Bob. await createRoom(alicePage, "TestRoom", true); - await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId); + await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId, { confirmUnknownUser: true }); // Bob accepts the invite. await bobPage.getByRole("option", { name: "TestRoom" }).click(); @@ -214,7 +214,7 @@ test.describe("Other people's devices section in Encryption tab", () => { // Alice creates the room and invite Bob. await createRoom(alicePage, "TestRoom", true); - await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId); + await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId, { confirmUnknownUser: true }); // Bob accepts the invite. await bobPage.getByRole("option", { name: "TestRoom" }).click(); diff --git a/apps/web/playwright/pages/ElementAppPage.ts b/apps/web/playwright/pages/ElementAppPage.ts index e5a1aab31c1..1d285070107 100644 --- a/apps/web/playwright/pages/ElementAppPage.ts +++ b/apps/web/playwright/pages/ElementAppPage.ts @@ -233,15 +233,30 @@ export class ElementAppPage { * Open the room info panel, and use it to send an invite to the given user. * * @param userId - The user to invite to the room. + * @param options - Options object */ - public async inviteUserToCurrentRoom(userId: string): Promise { + public async inviteUserToCurrentRoom( + userId: string, + options?: { + /** If true, expect and acknowledge "Confirm inviting new users" page */ + confirmUnknownUser?: boolean; + }, + ): Promise { const rightPanel = await this.openRoomInfoPanel(); await rightPanel.getByRole("menuitem", { name: "Invite" }).click(); - const input = this.page.getByRole("dialog").getByTestId("invite-dialog-input"); + const dialogLocator = this.page.getByRole("dialog"); + const input = dialogLocator.getByTestId("invite-dialog-input"); await input.fill(userId); await input.press("Enter"); - await this.page.getByRole("dialog").getByRole("button", { name: "Invite" }).click(); + await dialogLocator.getByRole("button", { name: "Invite" }).click(); + + if (options?.confirmUnknownUser) { + await expect( + dialogLocator.getByRole("heading", { name: "Invite new contacts to this room?" }), + ).toBeVisible(); + await dialogLocator.getByRole("button", { name: "Invite" }).click(); + } } /** diff --git a/apps/web/playwright/snapshots/invite/invite-dialog.spec.ts/confirm-chat-with-new-contact-linux.png b/apps/web/playwright/snapshots/invite/invite-dialog.spec.ts/confirm-chat-with-new-contact-linux.png new file mode 100644 index 00000000000..4cdb24a1739 Binary files /dev/null and b/apps/web/playwright/snapshots/invite/invite-dialog.spec.ts/confirm-chat-with-new-contact-linux.png differ diff --git a/apps/web/playwright/snapshots/invite/invite-dialog.spec.ts/confirm-invite-new-contact-linux.png b/apps/web/playwright/snapshots/invite/invite-dialog.spec.ts/confirm-invite-new-contact-linux.png new file mode 100644 index 00000000000..cf961af67a6 Binary files /dev/null and b/apps/web/playwright/snapshots/invite/invite-dialog.spec.ts/confirm-invite-new-contact-linux.png differ diff --git a/apps/web/res/css/_common.pcss b/apps/web/res/css/_common.pcss index f3a9fdcb944..d55cb076068 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 044a06bcb03..bac006d7f5e 100644 --- a/apps/web/res/css/_components.pcss +++ b/apps/web/res/css/_components.pcss @@ -171,6 +171,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 00000000000..085d9dfa5d5 --- /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 be208b24a20..a9271292415 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,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 { + 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 => { if (this.props.kind !== InviteKind.CallTransfer) return; if (this.state.currentTabId == TabId.UserDirectory) { @@ -1123,14 +1150,49 @@ 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". */ private renderMainTab(): JSX.Element { let helpText; let buttonText; - let goButtonFn: (() => Promise) | null = null; - const identityServersEnabled = SettingsStore.getValue(UIFeature.IdentityServer); const cli = MatrixClientPeg.safeGet(); @@ -1167,7 +1229,6 @@ export default class InviteDialog extends React.PureComponent { + this.onGoButtonPressed().catch((e) => logErrorAndShowErrorDialog("Error processing invites", e)); + }; + return (

{helpText}

@@ -1223,7 +1287,7 @@ export default class InviteDialog extends React.PureComponent @@ -1235,12 +1299,49 @@ export default class InviteDialog extends React.PureComponent { + // 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 ( + { + 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"); diff --git a/apps/web/src/components/views/dialogs/invite/DMRoomTile.tsx b/apps/web/src/components/views/dialogs/invite/DMRoomTile.tsx index 954e792b17f..8998977cf22 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 00000000000..a2c4b4a01a2 --- /dev/null +++ b/apps/web/src/components/views/dialogs/invite/UnknownIdentityUsersWarningDialog.tsx @@ -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) => { + const userListItem = useCallback((u: Member) => , []); + + 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 = ; + 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 = ; + break; + } + + return ( + +
+ +

{headerText}

+
+
+ +
    + {props.users.map(userListItem)} +
+ +
{buttons}
+
+ ); +}; + +const DmButtons: React.FC<{ onContinue: () => void; onCancel: () => void }> = (props) => { + return ( + <> + + + + ); +}; + +const InviteButtons: React.FC<{ onInvite: () => void; onRemove: () => void }> = (props) => { + return ( + <> + + + + ); +}; + +export default UnknownIdentityUsersWarningDialog; diff --git a/apps/web/src/i18n/strings/en_EN.json b/apps/web/src/i18n/strings/en_EN.json index b216dc45bdf..3f628fc9582 100644 --- a/apps/web/src/i18n/strings/en_EN.json +++ b/apps/web/src/i18n/strings/en_EN.json @@ -1368,6 +1368,14 @@ "impossible_dialog_title": "Integrations not allowed" }, "invite": { + "confirm_unknown_users": { + "invite_subtitle": "You currently don't have any chats with these contacts. Confirm inviting them to this room before continuing.", + "invite_title": "Invite new contacts to this room?", + "start_chat_subtitle_multiple_users": "You currently don't have any chats with these people. Confirm inviting them before continuing.", + "start_chat_subtitle_one_user": "You currently don't have any chats with this person. Confirm inviting them before continuing.", + "start_chat_title_multiple_users": "Start a chat with these new contacts?", + "start_chat_title_one_user": "Start a chat with this new contact?" + }, "email_caption": "Invite by email", "email_limit_one": "Invites by email can only be sent one at a time", "email_use_default_is": "Use an identity server to invite by email. Use the default (%(defaultIdentityServerName)s) or manage in Settings.", 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 3f9eb6ac5ca..4f3a80736c0 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 @@ -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( + , + ); + }); + + 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(); + }); + }); + }); }); diff --git a/apps/web/test/unit-tests/components/views/dialogs/invite/UnknownIdentityUsersWarningDialog-test.tsx b/apps/web/test/unit-tests/components/views/dialogs/invite/UnknownIdentityUsersWarningDialog-test.tsx new file mode 100644 index 00000000000..f3b7c0476fe --- /dev/null +++ b/apps/web/test/unit-tests/components/views/dialogs/invite/UnknownIdentityUsersWarningDialog-test.tsx @@ -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>): RenderResult { + const props1: ComponentProps = { + onContinue: () => {}, + onCancel: () => {}, + onRemove: () => {}, + screenName: undefined, + kind: InviteKind.Dm, + users: [], + ...props, + }; + return render(); +}