From acd1e37f1c542e35479c6d8faa175cced11e069b Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Wed, 5 Feb 2025 19:17:15 +0530 Subject: [PATCH 01/11] Create new verification section --- src/components/views/right_panel/UserInfo.tsx | 96 +++++++++++++++---- 1 file changed, 75 insertions(+), 21 deletions(-) diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index 3be37255765..7e483acf7cc 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -146,27 +146,6 @@ async function openDmForUser(matrixClient: MatrixClient, user: Member): Promise< await startDmOnFirstMessage(matrixClient, [startDmUser]); } -type SetUpdating = (updating: boolean) => void; - -function useHasCrossSigningKeys( - cli: MatrixClient, - member: User, - canVerify: boolean, - setUpdating: SetUpdating, -): boolean | undefined { - return useAsyncMemo(async () => { - if (!canVerify) { - return undefined; - } - setUpdating(true); - try { - return await cli.getCrypto()?.userHasCrossSigningKeys(member.userId, true); - } finally { - setUpdating(false); - } - }, [cli, member, canVerify]); -} - /** * Display one device and the related actions * @param userId current user id @@ -1400,6 +1379,81 @@ export const useDevices = (userId: string): IDevice[] | undefined | null => { return devices; }; +function useHasCrossSigningKeys(cli: MatrixClient, member: User, canVerify: boolean): boolean | undefined { + return useAsyncMemo(async () => { + if (!canVerify) return undefined; + return await cli.getCrypto()?.userHasCrossSigningKeys(member.userId, true); + }, [cli, member, canVerify]); +} + +const VerificationSection: React.FC<{ + member: User | RoomMember; + devices: IDevice[]; +}> = ({ member, devices }) => { + const cli = useContext(MatrixClientContext); + let content; + const homeserverSupportsCrossSigning = useHomeserverSupportsCrossSigning(cli); + + const userTrust = useAsyncMemo( + async () => cli.getCrypto()?.getUserVerificationStatus(member.userId), + [member.userId], + // the user verification status is not initialized + undefined, + ); + const hasUserVerificationStatus = Boolean(userTrust); + const isUserVerified = Boolean(userTrust?.isVerified()); + const isMe = member.userId === cli.getUserId(); + const canVerify = + hasUserVerificationStatus && + homeserverSupportsCrossSigning && + !isUserVerified && + !isMe && + devices && + devices.length > 0; + console.log("canVerify", canVerify, isMe); + + const hasCrossSigningKeys = useHasCrossSigningKeys(cli, member as User, canVerify); + + if (isUserVerified) { + content = ( + + + + {_t("common|verified")} + + + ); + } else if (hasCrossSigningKeys === undefined) { + // We are still fetching the cross-signing keys for the user, show spinner. + content = ; + } else if (canVerify && hasCrossSigningKeys) { + content = ( +
+ +
+ ); + } else { + content = ( + + ({_t("user_info|verification_unavailable")}) + + ); + } + + return ( + + {content} + + ); +}; + const BasicUserInfo: React.FC<{ room: Room; member: User | RoomMember; From fb19de56474e042c265829f426e79e698ad0fe5a Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Wed, 5 Feb 2025 19:18:12 +0530 Subject: [PATCH 02/11] Remove old code and use new VerificationSection --- src/components/views/right_panel/UserInfo.tsx | 281 +----------------- 1 file changed, 14 insertions(+), 267 deletions(-) diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index 7e483acf7cc..501e0a42388 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -25,7 +25,8 @@ import { import { KnownMembership } from "matrix-js-sdk/src/types"; import { UserVerificationStatus, VerificationRequest, CryptoEvent } from "matrix-js-sdk/src/crypto-api"; import { logger } from "matrix-js-sdk/src/logger"; -import { Heading, MenuItem, Text, Tooltip } from "@vector-im/compound-web"; +import { Badge, Button, Heading, InlineSpinner, MenuItem, Text, Tooltip } from "@vector-im/compound-web"; +import VerifiedIcon from "@vector-im/compound-design-tokens/assets/web/icons/verified"; import ChatIcon from "@vector-im/compound-design-tokens/assets/web/icons/chat"; import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check"; import ShareIcon from "@vector-im/compound-design-tokens/assets/web/icons/share"; @@ -45,7 +46,6 @@ import DMRoomMap from "../../../utils/DMRoomMap"; import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; import SdkConfig from "../../../SdkConfig"; import MultiInviter from "../../../utils/MultiInviter"; -import E2EIcon from "../rooms/E2EIcon"; import { useTypedEventEmitter } from "../../../hooks/useEventEmitter"; import { textualPowerLevel } from "../../../Roles"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; @@ -232,144 +232,6 @@ export function DeviceItem({ } } -/** - * Display a list of devices - * @param devices devices to display - * @param userId current user id - * @param loading displays a spinner instead of the device section - * @param isUserVerified is false when - * - the user is not verified, or - * - `MatrixClient.getCrypto.getUserVerificationStatus` async call is in progress (in which case `loading` will also be `true`) - * @constructor - */ -function DevicesSection({ - devices, - userId, - loading, - isUserVerified, -}: { - devices: IDevice[]; - userId: string; - loading: boolean; - isUserVerified: boolean; -}): JSX.Element { - const cli = useContext(MatrixClientContext); - - const [isExpanded, setExpanded] = useState(false); - - const deviceTrusts = useAsyncMemo(() => { - const cryptoApi = cli.getCrypto(); - if (!cryptoApi) return Promise.resolve(undefined); - return Promise.all(devices.map((d) => cryptoApi.getDeviceVerificationStatus(userId, d.deviceId))); - }, [cli, userId, devices]); - - if (loading || deviceTrusts === undefined) { - // still loading - return ; - } - const isMe = userId === cli.getUserId(); - - let expandSectionDevices: IDevice[] = []; - const unverifiedDevices: IDevice[] = []; - - let expandCountCaption; - let expandHideCaption; - let expandIconClasses = "mx_E2EIcon"; - - const dehydratedDeviceIds: string[] = []; - for (const device of devices) { - if (device.dehydrated) { - dehydratedDeviceIds.push(device.deviceId); - } - } - // If the user has exactly one device marked as dehydrated, we consider - // that as the dehydrated device, and hide it as a normal device (but - // indicate that the user is using a dehydrated device). If the user has - // more than one, that is anomalous, and we show all the devices so that - // nothing is hidden. - const dehydratedDeviceId: string | undefined = dehydratedDeviceIds.length == 1 ? dehydratedDeviceIds[0] : undefined; - let dehydratedDeviceInExpandSection = false; - - if (isUserVerified) { - for (let i = 0; i < devices.length; ++i) { - const device = devices[i]; - const deviceTrust = deviceTrusts[i]; - // For your own devices, we use the stricter check of cross-signing - // verification to encourage everyone to trust their own devices via - // cross-signing so that other users can then safely trust you. - // For other people's devices, the more general verified check that - // includes locally verified devices can be used. - const isVerified = deviceTrust && (isMe ? deviceTrust.crossSigningVerified : deviceTrust.isVerified()); - - if (isVerified) { - // don't show dehydrated device as a normal device, if it's - // verified - if (device.deviceId === dehydratedDeviceId) { - dehydratedDeviceInExpandSection = true; - } else { - expandSectionDevices.push(device); - } - } else { - unverifiedDevices.push(device); - } - } - expandCountCaption = _t("user_info|count_of_verified_sessions", { count: expandSectionDevices.length }); - expandHideCaption = _t("user_info|hide_verified_sessions"); - expandIconClasses += " mx_E2EIcon_verified"; - } else { - if (dehydratedDeviceId) { - devices = devices.filter((device) => device.deviceId !== dehydratedDeviceId); - dehydratedDeviceInExpandSection = true; - } - expandSectionDevices = devices; - expandCountCaption = _t("user_info|count_of_sessions", { count: devices.length }); - expandHideCaption = _t("user_info|hide_sessions"); - expandIconClasses += " mx_E2EIcon_normal"; - } - - let expandButton; - if (expandSectionDevices.length) { - if (isExpanded) { - expandButton = ( - setExpanded(false)}> -
{expandHideCaption}
-
- ); - } else { - expandButton = ( - setExpanded(true)}> -
-
{expandCountCaption}
- - ); - } - } - - let deviceList = unverifiedDevices.map((device, i) => { - return ; - }); - if (isExpanded) { - const keyStart = unverifiedDevices.length; - deviceList = deviceList.concat( - expandSectionDevices.map((device, i) => { - return ( - - ); - }), - ); - if (dehydratedDeviceInExpandSection) { - deviceList.push(
{_t("user_info|dehydrated_device_enabled")}
); - } - } - - return ( -
-
{deviceList}
-
{expandButton}
-
- ); -} - const MessageButton = ({ member }: { member: Member }): JSX.Element => { const cli = useContext(MatrixClientContext); const [busy, setBusy] = useState(false); @@ -1457,9 +1319,7 @@ const VerificationSection: React.FC<{ const BasicUserInfo: React.FC<{ room: Room; member: User | RoomMember; - devices: IDevice[]; - isRoomEncrypted: boolean; -}> = ({ room, member, devices, isRoomEncrypted }) => { +}> = ({ room, member }) => { const cli = useContext(MatrixClientContext); const powerLevels = useRoomPowerLevels(cli, room); @@ -1557,111 +1417,10 @@ const BasicUserInfo: React.FC<{ spinner = ; } - // only display the devices list if our client supports E2E - const cryptoEnabled = Boolean(cli.getCrypto()); - - let text; - if (!isRoomEncrypted) { - if (!cryptoEnabled) { - text = _t("encryption|unsupported"); - } else if (room && !room.isSpaceRoom()) { - text = _t("user_info|room_unencrypted"); - } - } else if (!room.isSpaceRoom()) { - text = _t("user_info|room_encrypted"); - } - - let verifyButton; - const homeserverSupportsCrossSigning = useHomeserverSupportsCrossSigning(cli); - - const userTrust = useAsyncMemo( - async () => cli.getCrypto()?.getUserVerificationStatus(member.userId), - [member.userId], - // the user verification status is not initialized - undefined, - ); - const hasUserVerificationStatus = Boolean(userTrust); - const isUserVerified = Boolean(userTrust?.isVerified()); const isMe = member.userId === cli.getUserId(); - const canVerify = - hasUserVerificationStatus && - homeserverSupportsCrossSigning && - !isUserVerified && - !isMe && - devices && - devices.length > 0; - - const setUpdating: SetUpdating = (updating) => { - setPendingUpdateCount((count) => count + (updating ? 1 : -1)); - }; - const hasCrossSigningKeys = useHasCrossSigningKeys(cli, member as User, canVerify, setUpdating); - - // Display the spinner only when - // - the devices are not populated yet, or - // - the crypto is available and we don't have the user verification status yet - const showDeviceListSpinner = (cryptoEnabled && !hasUserVerificationStatus) || devices === undefined; - if (canVerify) { - if (hasCrossSigningKeys !== undefined) { - // Note: mx_UserInfo_verifyButton is for the end-to-end tests - verifyButton = ( -
- verifyUser(cli, member as User)} - > - {_t("action|verify")} - -
- ); - } else if (!showDeviceListSpinner) { - // HACK: only show a spinner if the device section spinner is not shown, - // to avoid showing a double spinner - // We should ask for a design that includes all the different loading states here - verifyButton = ; - } - } - - let editDevices; - if (member.userId == cli.getUserId()) { - editDevices = ( -
- { - dis.dispatch({ - action: Action.ViewUserDeviceSettings, - }); - }} - > - {_t("user_info|edit_own_devices")} - -
- ); - } - - const securitySection = ( - -

{_t("common|security")}

-

{text}

- {verifyButton} - {cryptoEnabled && ( - - )} - {editDevices} -
- ); return ( - {securitySection} - {memberDetails} - {adminToolsContainer} - {!isMe && ( )} - {spinner} ); @@ -1687,9 +1443,10 @@ export type Member = User | RoomMember; export const UserInfoHeader: React.FC<{ member: Member; - e2eStatus?: E2EStatus; + devices: IDevice[]; roomId?: string; -}> = ({ member, e2eStatus, roomId }) => { + hideVerificationSection?: boolean; +}> = ({ member, devices, roomId, hideVerificationSection }) => { const cli = useContext(MatrixClientContext); const onMemberAvatarClick = useCallback(() => { @@ -1740,7 +1497,6 @@ export const UserInfoHeader: React.FC<{ const timezoneInfo = useUserTimezone(cli, member.userId); - const e2eIcon = e2eStatus ? : null; const userIdentifier = UserIdentifierCustomisations.getDisplayUserIdentifier?.(member.userId, { roomId, withDisplayName: true, @@ -1769,7 +1525,6 @@ export const UserInfoHeader: React.FC<{ {displayName} - {e2eIcon} {presenceLabel} @@ -1788,6 +1543,7 @@ export const UserInfoHeader: React.FC<{ + {!hideVerificationSection && } ); @@ -1811,13 +1567,6 @@ const UserInfo: React.FC = ({ user, room, onClose, phase = RightPanelPha const isRoomEncrypted = useIsEncrypted(cli, room); const devices = useDevices(user.userId) ?? []; - const e2eStatus = useAsyncMemo(async () => { - if (!isRoomEncrypted || !devices) { - return undefined; - } - return await getE2EStatus(cli, user.userId, devices); - }, [cli, isRoomEncrypted, user.userId, devices]); - const classes = ["mx_UserInfo"]; let cardState: IRightPanelCardState = {}; @@ -1833,14 +1582,7 @@ const UserInfo: React.FC = ({ user, room, onClose, phase = RightPanelPha let content: JSX.Element | undefined; switch (phase) { case RightPanelPhases.MemberInfo: - content = ( - - ); + content = ; break; case RightPanelPhases.EncryptionPanel: classes.push("mx_UserInfo_smallAvatar"); @@ -1865,7 +1607,12 @@ const UserInfo: React.FC = ({ user, room, onClose, phase = RightPanelPha const header = ( <> - + ); From 4b7ad0636d1a62334383d026ccafc76d2f5fc891 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Wed, 5 Feb 2025 19:18:56 +0530 Subject: [PATCH 03/11] Add styling and translation --- res/css/views/right_panel/_UserInfo.pcss | 26 ++++++++++++++++++++---- src/i18n/strings/en_EN.json | 15 +------------- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/res/css/views/right_panel/_UserInfo.pcss b/res/css/views/right_panel/_UserInfo.pcss index 7a67986ae83..a8e9ff70a4c 100644 --- a/res/css/views/right_panel/_UserInfo.pcss +++ b/res/css/views/right_panel/_UserInfo.pcss @@ -37,10 +37,6 @@ Please see LICENSE files in the repository root for full details. padding: var(--cpd-space-2x) 0 var(--cpd-space-4x); margin: 0 var(--cpd-space-4x); - .mx_UserInfo_container_verifyButton { - margin-top: $spacing-8; - } - & + .mx_UserInfo_container { border-top: 1px solid $separator; } @@ -180,6 +176,28 @@ Please see LICENSE files in the repository root for full details. opacity: 1; } + .mx_UserInfo_verification { + margin-top: var(--cpd-space-4x); + height: 36px; + + .mx_UserInfo_verified_badge { + width: 68px; + height: 20px; + + .mx_UserInfo_verified_icon { + flex-shrink: 0; + } + + .mx_UserInfo_verified_label { + margin: 0; + } + } + + .mx_UserInfo_verification_unavailable { + color: var(--cpd-color-text-secondary); + } + } + .mx_UserInfo_memberDetails { .mx_UserInfo_profileField { display: flex; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 4ba1444c41c..941e73dc442 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -548,7 +548,6 @@ "saved": "Saved", "saving": "Saving…", "secure_backup": "Secure Backup", - "security": "Security", "select_all": "Select all", "server": "Server", "settings": "Settings", @@ -971,7 +970,6 @@ "title": "Not Trusted" }, "unable_to_setup_keys_error": "Unable to set up keys", - "unsupported": "This client does not support end-to-end encryption.", "verification": { "accepting": "Accepting…", "after_new_login": { @@ -3785,18 +3783,9 @@ "ban_room_confirm_title": "Ban from %(roomName)s", "ban_space_everything": "Ban them from everything I'm able to", "ban_space_specific": "Ban them from specific things I'm able to", - "count_of_sessions": { - "one": "%(count)s session", - "other": "%(count)s sessions" - }, - "count_of_verified_sessions": { - "one": "1 verified session", - "other": "%(count)s verified sessions" - }, "deactivate_confirm_action": "Deactivate user", "deactivate_confirm_description": "Deactivating this user will log them out and prevent them from logging back in. Additionally, they will leave all the rooms they are in. This action cannot be reversed. Are you sure you want to deactivate this user?", "deactivate_confirm_title": "Deactivate user?", - "dehydrated_device_enabled": "Offline device enabled", "demote_button": "Demote", "demote_self_confirm_description_space": "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the space it will be impossible to regain privileges.", "demote_self_confirm_room": "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the room it will be impossible to regain privileges.", @@ -3804,15 +3793,12 @@ "disinvite_button_room": "Disinvite from room", "disinvite_button_room_name": "Disinvite from %(roomName)s", "disinvite_button_space": "Disinvite from space", - "edit_own_devices": "Edit devices", "error_ban_user": "Failed to ban user", "error_deactivate": "Failed to deactivate user", "error_kicking_user": "Failed to remove user", "error_mute_user": "Failed to mute user", "error_revoke_3pid_invite_description": "Could not revoke the invite. The server may be experiencing a temporary problem or you do not have sufficient permissions to revoke the invite.", "error_revoke_3pid_invite_title": "Failed to revoke invite", - "hide_sessions": "Hide sessions", - "hide_verified_sessions": "Hide verified sessions", "ignore_button": "Ignore", "ignore_confirm_description": "All messages and invites from this user will be hidden. Are you sure you want to ignore them?", "ignore_confirm_title": "Ignore %(user)s", @@ -3856,6 +3842,7 @@ "unban_space_specific": "Unban them from specific things I'm able to", "unban_space_warning": "They won't be able to access whatever you're not an admin of.", "unignore_button": "Unignore", + "verification_unavailable": "User verification unavailable", "verify_button": "Verify User", "verify_explainer": "For extra security, verify this user by checking a one-time code on both of your devices." }, From eeb262703d27725053ecc03102a90fdec24f80e4 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Wed, 5 Feb 2025 19:20:54 +0530 Subject: [PATCH 04/11] Fix tests --- playwright/e2e/user-view/user-view.spec.ts | 1 - .../user-view.spec.ts/user-info-linux.png | Bin 20963 -> 19384 bytes .../views/right_panel/UserInfo-test.tsx | 271 ++----------- .../__snapshots__/UserInfo-test.tsx.snap | 363 ++++++++++++++---- 4 files changed, 322 insertions(+), 313 deletions(-) diff --git a/playwright/e2e/user-view/user-view.spec.ts b/playwright/e2e/user-view/user-view.spec.ts index f3745e78595..de97133e6a0 100644 --- a/playwright/e2e/user-view/user-view.spec.ts +++ b/playwright/e2e/user-view/user-view.spec.ts @@ -19,7 +19,6 @@ test.describe("UserView", () => { const rightPanel = page.locator("#mx_RightPanel"); await expect(rightPanel.getByRole("heading", { name: bot.credentials.displayName, exact: true })).toBeVisible(); - await expect(rightPanel.getByText("1 session")).toBeVisible(); await expect(rightPanel).toMatchScreenshot("user-info.png", { mask: [page.locator(".mx_UserInfo_profile_mxid")], css: ` diff --git a/playwright/snapshots/user-view/user-view.spec.ts/user-info-linux.png b/playwright/snapshots/user-view/user-view.spec.ts/user-info-linux.png index 4e305879350fc9f5c09a5386ef424f204cd2174a..bd47484596274a70a123cb9aeb2babc06e894c05 100644 GIT binary patch literal 19384 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 literal 20963 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 z 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
ADE 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νQhd zYzx=&b2;2pX*R9TF`y~1C0@Gt%>INgZ%U4`>?)CLm9EYYZ3(g0ex(l-c*exX&iU0X zj#CBj7IH^rY;2-ba-e8Z^gPDmqx;kVAoFlujG_WPDf)`^v;uf4yozjIP~l?ixi;~e zI~mB`b$r}4wEL}lMtH5DTCiJt!$zy>aoH4b`A?P4Ca14ZvFLHpZogure&~YW`U2mTDw;DF z2jhGuBR-3Q*OtQ#qoPRbNA@052M_0ZFJIN}y7(Ixp&8w?M?smtFeuLP0rt3{Fae!E zdkAao+1L9WQCh%e%ekjtz2fbs{p5w4^c%yz-oAwJ9ii&KL|=o1&0L{bvfon8^-jV> z%6*ExO_oNRA)<9OU7&6gkg8#-37r|f@Tcs1FAfkrtkKRpheFI` zcTL{~i8O(11Bw{YFzmGs{f%$u>zQkldA3OOG{X|a8aulQ<l^LQMW_AH0Jp7|4Gkp~}SuTUi&|#)nqu_sIN6-LrC(-!%uMHabHk z{f2-R2fS(>8GeM?1{YFYiA$DfFQR?tthPNvJ|L% zKkJ5R0);u6V4|+wVK{%c|00G|ar@H*Bzx`J6-c;cWW}MYC1hX!M_4A&5w;XCIYp1z z#@_e1oi{7q?Vh+Gf^D0W`~Sb+3wWB3I)OnE7xDm z$V==9R=PRL7t<(vH8j2;thQBld%`9%O^g5m?2r@biMyV%EJCQCbV@rls3GUB_`~$B^zRxMR9x_vdaNLy~BLb?BPsmbOqE zdIF>4RX7`rr{s|dZ;Vt&uEml)-(Q)J1effLyG~ d|F=Ykz_;Lu#Q0VG?rI~z+Uf?Bdimb7{{ZWHTj2lz diff --git a/test/unit-tests/components/views/right_panel/UserInfo-test.tsx b/test/unit-tests/components/views/right_panel/UserInfo-test.tsx index db7af71c242..f83dd7dc708 100644 --- a/test/unit-tests/components/views/right_panel/UserInfo-test.tsx +++ b/test/unit-tests/components/views/right_panel/UserInfo-test.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { fireEvent, render, screen, cleanup, act, within, waitForElementToBeRemoved } from "jest-matrix-react"; +import { fireEvent, render, screen, cleanup, act, waitForElementToBeRemoved, waitFor } from "jest-matrix-react"; import userEvent from "@testing-library/user-event"; import { Mocked, mocked } from "jest-mock"; import { Room, User, MatrixClient, RoomMember, MatrixEvent, EventType, Device } from "matrix-js-sdk/src/matrix"; @@ -42,7 +42,6 @@ import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext import MultiInviter from "../../../../../src/utils/MultiInviter"; import * as mockVerification from "../../../../../src/verification"; import Modal from "../../../../../src/Modal"; -import { E2EStatus } from "../../../../../src/utils/ShieldUtils"; import { DirectoryMember, startDmOnFirstMessage } from "../../../../../src/utils/direct-messages"; import { clearAllModals, flushPromises } from "../../../../test-utils"; import ErrorDialog from "../../../../../src/components/views/dialogs/ErrorDialog"; @@ -437,20 +436,6 @@ describe("", () => { mockCrypto.getUserDeviceInfo.mockResolvedValue(userDeviceMap); }); - it("renders a device list which can be expanded", async () => { - renderComponent(); - await flushPromises(); - - // check the button exists with the expected text - const devicesButton = screen.getByRole("button", { name: "1 session" }); - - // click it - await userEvent.click(devicesButton); - - // there should now be a button with the device id which should contain the device name - expect(screen.getByRole("button", { name: "my device" })).toBeInTheDocument(); - }); - it("renders ", async () => { mockCrypto.getUserVerificationStatus.mockResolvedValue(new UserVerificationStatus(false, false, false)); @@ -460,190 +445,9 @@ describe("", () => { room: mockRoom, }); await flushPromises(); - - await expect(screen.findByRole("button", { name: "Verify" })).resolves.toBeInTheDocument(); expect(container).toMatchSnapshot(); }); - describe("device dehydration", () => { - it("hides a verified dehydrated device (unverified user)", async () => { - const device1 = new Device({ - deviceId: "d1", - userId: defaultUserId, - displayName: "my device", - algorithms: [], - keys: new Map(), - }); - const device2 = new Device({ - deviceId: "d2", - userId: defaultUserId, - displayName: "dehydrated device", - algorithms: [], - keys: new Map(), - dehydrated: true, - }); - const devicesMap = new Map([ - [device1.deviceId, device1], - [device2.deviceId, device2], - ]); - const userDeviceMap = new Map>([[defaultUserId, devicesMap]]); - mockCrypto.getUserDeviceInfo.mockResolvedValue(userDeviceMap); - - renderComponent({ room: mockRoom }); - await flushPromises(); - - // check the button exists with the expected text (the dehydrated device shouldn't be counted) - const devicesButton = screen.getByRole("button", { name: "1 session" }); - - // click it - await act(() => { - return userEvent.click(devicesButton); - }); - - // there should now be a button with the non-dehydrated device ID - expect(screen.getByRole("button", { name: "my device" })).toBeInTheDocument(); - - // but not for the dehydrated device ID - expect(screen.queryByRole("button", { name: "dehydrated device" })).not.toBeInTheDocument(); - - // there should be a line saying that the user has "Offline device" enabled - expect(screen.getByText("Offline device enabled")).toBeInTheDocument(); - }); - - it("hides a verified dehydrated device (verified user)", async () => { - const device1 = new Device({ - deviceId: "d1", - userId: defaultUserId, - displayName: "my device", - algorithms: [], - keys: new Map(), - }); - const device2 = new Device({ - deviceId: "d2", - userId: defaultUserId, - displayName: "dehydrated device", - algorithms: [], - keys: new Map(), - dehydrated: true, - }); - const devicesMap = new Map([ - [device1.deviceId, device1], - [device2.deviceId, device2], - ]); - const userDeviceMap = new Map>([[defaultUserId, devicesMap]]); - mockCrypto.getUserDeviceInfo.mockResolvedValue(userDeviceMap); - mockCrypto.getUserVerificationStatus.mockResolvedValue(new UserVerificationStatus(true, true, true)); - mockCrypto.getDeviceVerificationStatus.mockResolvedValue({ - isVerified: () => true, - } as DeviceVerificationStatus); - - renderComponent({ room: mockRoom }); - await flushPromises(); - - // check the button exists with the expected text (the dehydrated device shouldn't be counted) - const devicesButton = screen.getByRole("button", { name: "1 verified session" }); - - // click it - await act(() => { - return userEvent.click(devicesButton); - }); - - // there should now be a button with the non-dehydrated device ID - expect(screen.getByTitle("d1")).toBeInTheDocument(); - - // but not for the dehydrated device ID - expect(screen.queryByTitle("d2")).not.toBeInTheDocument(); - - // there should be a line saying that the user has "Offline device" enabled - expect(screen.getByText("Offline device enabled")).toBeInTheDocument(); - }); - - it("shows an unverified dehydrated device", async () => { - const device1 = new Device({ - deviceId: "d1", - userId: defaultUserId, - displayName: "my device", - algorithms: [], - keys: new Map(), - }); - const device2 = new Device({ - deviceId: "d2", - userId: defaultUserId, - displayName: "dehydrated device", - algorithms: [], - keys: new Map(), - dehydrated: true, - }); - const devicesMap = new Map([ - [device1.deviceId, device1], - [device2.deviceId, device2], - ]); - const userDeviceMap = new Map>([[defaultUserId, devicesMap]]); - mockCrypto.getUserDeviceInfo.mockResolvedValue(userDeviceMap); - mockCrypto.getUserVerificationStatus.mockResolvedValue(new UserVerificationStatus(true, true, true)); - - renderComponent({ room: mockRoom }); - await flushPromises(); - - // the dehydrated device should be shown as an unverified device, which means - // there should now be a button with the device id ... - const deviceButton = screen.getByRole("button", { name: "dehydrated device" }); - - // ... which should contain the device name - expect(within(deviceButton).getByText("dehydrated device")).toBeInTheDocument(); - }); - - it("shows dehydrated devices if there is more than one", async () => { - const device1 = new Device({ - deviceId: "d1", - userId: defaultUserId, - displayName: "dehydrated device 1", - algorithms: [], - keys: new Map(), - dehydrated: true, - }); - const device2 = new Device({ - deviceId: "d2", - userId: defaultUserId, - displayName: "dehydrated device 2", - algorithms: [], - keys: new Map(), - dehydrated: true, - }); - const devicesMap = new Map([ - [device1.deviceId, device1], - [device2.deviceId, device2], - ]); - const userDeviceMap = new Map>([[defaultUserId, devicesMap]]); - mockCrypto.getUserDeviceInfo.mockResolvedValue(userDeviceMap); - - renderComponent({ room: mockRoom }); - await flushPromises(); - - // check the button exists with the expected text (the dehydrated device shouldn't be counted) - const devicesButton = screen.getByRole("button", { name: "2 sessions" }); - - // click it - await act(() => { - return userEvent.click(devicesButton); - }); - - // the dehydrated devices should be shown as an unverified device, which means - // there should now be a button with the first dehydrated device... - const device1Button = screen.getByRole("button", { name: "dehydrated device 1" }); - expect(device1Button).toBeVisible(); - - // ... which should contain the device name - expect(within(device1Button).getByText("dehydrated device 1")).toBeInTheDocument(); - // and a button with the second dehydrated device... - const device2Button = screen.getByRole("button", { name: "dehydrated device 2" }); - expect(device2Button).toBeVisible(); - - // ... which should contain the device name - expect(within(device2Button).getByText("dehydrated device 2")).toBeInTheDocument(); - }); - }); - it("should render a deactivate button for users of the same server if we are a server admin", async () => { mockClient.isSynapseAdministrator.mockResolvedValue(true); mockClient.getDomain.mockReturnValue("example.com"); @@ -660,34 +464,6 @@ describe("", () => { expect(container).toMatchSnapshot(); }); }); - - describe("with an encrypted room", () => { - beforeEach(() => { - jest.spyOn(mockClient.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(true); - }); - - it("renders unverified user info", async () => { - mockCrypto.getUserVerificationStatus.mockResolvedValue(new UserVerificationStatus(false, false, false)); - renderComponent({ room: mockRoom }); - await flushPromises(); - - const userHeading = screen.getByRole("heading", { name: /@user:example.com/ }); - - // there should be a "normal" E2E padlock - expect(userHeading.getElementsByClassName("mx_E2EIcon_normal")).toHaveLength(1); - }); - - it("renders verified user info", async () => { - mockCrypto.getUserVerificationStatus.mockResolvedValue(new UserVerificationStatus(true, false, false)); - renderComponent({ room: mockRoom }); - await flushPromises(); - - const userHeading = screen.getByRole("heading", { name: /@user:example.com/ }); - - // there should be a "verified" E2E padlock - expect(userHeading.getElementsByClassName("mx_E2EIcon_verified")).toHaveLength(1); - }); - }); }); describe("", () => { @@ -699,33 +475,52 @@ describe("", () => { }; const renderComponent = (props = {}) => { + const device1 = new Device({ + deviceId: "d1", + userId: defaultUserId, + displayName: "my device", + algorithms: [], + keys: new Map(), + }); + const devicesMap = new Map([[device1.deviceId, device1]]); + const userDeviceMap = new Map>([[defaultUserId, devicesMap]]); + mockCrypto.getUserDeviceInfo.mockResolvedValue(userDeviceMap); + mockClient.doesServerSupportUnstableFeature.mockResolvedValue(true); const Wrapper = (wrapperProps = {}) => { return ; }; - return render(, { + return render(, { wrapper: Wrapper, }); }; - it("does not render an e2e icon in the header if e2eStatus prop is undefined", () => { + it("renders custom user identifiers in the header", () => { renderComponent(); - const header = screen.getByRole("heading", { name: defaultUserId }); - - expect(header.getElementsByClassName("mx_E2EIcon")).toHaveLength(0); + expect(screen.getByText("customUserIdentifier")).toBeInTheDocument(); }); - it("renders an e2e icon in the header if e2eStatus prop is defined", () => { - renderComponent({ e2eStatus: E2EStatus.Normal }); - const header = screen.getByRole("heading"); - - expect(header.getElementsByClassName("mx_E2EIcon")).toHaveLength(1); + it("renders verified badge when user is verified", async () => { + mockCrypto.getUserVerificationStatus.mockResolvedValue(new UserVerificationStatus(true, true, false)); + const { container } = renderComponent(); + await waitFor(() => expect(screen.getByText("Verified")).toBeInTheDocument()); + expect(container).toMatchSnapshot(); }); - it("renders custom user identifiers in the header", () => { - renderComponent(); + it("renders verify button", async () => { + mockCrypto.getUserVerificationStatus.mockResolvedValue(new UserVerificationStatus(false, false, false)); + mockCrypto.userHasCrossSigningKeys.mockResolvedValue(true); + const { container } = renderComponent(); + await waitFor(() => expect(screen.getByText("Verify User")).toBeInTheDocument()); + expect(container).toMatchSnapshot(); + }); - expect(screen.getByText("customUserIdentifier")).toBeInTheDocument(); + it("renders verification unavailable message", async () => { + mockCrypto.getUserVerificationStatus.mockResolvedValue(new UserVerificationStatus(false, false, false)); + mockCrypto.userHasCrossSigningKeys.mockResolvedValue(false); + const { container } = renderComponent(); + await waitFor(() => expect(screen.getByText("(User verification unavailable)")).toBeInTheDocument()); + expect(container).toMatchSnapshot(); }); }); diff --git a/test/unit-tests/components/views/right_panel/__snapshots__/UserInfo-test.tsx.snap b/test/unit-tests/components/views/right_panel/__snapshots__/UserInfo-test.tsx.snap index 5ba2d3b5fcb..8d75e8d6cec 100644 --- a/test/unit-tests/components/views/right_panel/__snapshots__/UserInfo-test.tsx.snap +++ b/test/unit-tests/components/views/right_panel/__snapshots__/UserInfo-test.tsx.snap @@ -88,7 +88,7 @@ exports[` with crypto enabled renders 1`] = `

+ + + +
+
+

+
+ @user:example.com +
+

+
+ Unknown +
+

+

+ customUserIdentifier +
+
+

+
+
+

+ (User verification unavailable) +

+
+
+
+`; + +exports[` renders verified badge when user is verified 1`] = ` +
+
+
+
+ +
+
+
+
+
+

+
+ @user:example.com +
+

+
+ Unknown +
+

+

+ customUserIdentifier +
+
+

+
+
+ + + + +

+ Verified +

+
+
+
+
+`; + +exports[` renders verify button 1`] = ` +
+
+
+
+ +
+
+
+
+
+

+
+ @user:example.com +
+

+
+ Unknown +
+

+

+ customUserIdentifier +
+
+

+
+
+
+ +
+
+
+
+`; From 66c22895b811b75a699ace4c59e235e64112002d Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Wed, 5 Feb 2025 20:07:55 +0530 Subject: [PATCH 05/11] Remove dead code --- src/components/views/right_panel/UserInfo.tsx | 26 ------------------- 1 file changed, 26 deletions(-) diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index 501e0a42388..ef3c398890b 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -107,32 +107,6 @@ export const disambiguateDevices = (devices: IDevice[]): void => { } }; -export const getE2EStatus = async ( - cli: MatrixClient, - userId: string, - devices: IDevice[], -): Promise => { - const crypto = cli.getCrypto(); - if (!crypto) return undefined; - const isMe = userId === cli.getUserId(); - const userTrust = await crypto.getUserVerificationStatus(userId); - if (!userTrust.isCrossSigningVerified()) { - return userTrust.wasCrossSigningVerified() ? E2EStatus.Warning : E2EStatus.Normal; - } - - const anyDeviceUnverified = await asyncSome(devices, async (device) => { - const { deviceId } = device; - // For your own devices, we use the stricter check of cross-signing - // verification to encourage everyone to trust their own devices via - // cross-signing so that other users can then safely trust you. - // For other people's devices, the more general verified check that - // includes locally verified devices can be used. - const deviceTrust = await crypto.getDeviceVerificationStatus(userId, deviceId); - return isMe ? !deviceTrust?.crossSigningVerified : !deviceTrust?.isVerified(); - }); - return anyDeviceUnverified ? E2EStatus.Warning : E2EStatus.Verified; -}; - /** * Converts the member to a DirectoryMember and starts a DM with them. */ From d10be1d00fca4c7fd1aeda61e4c7ea16797be34b Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Wed, 5 Feb 2025 20:08:14 +0530 Subject: [PATCH 06/11] Fix broken test --- .../components/structures/TabbedView-test.tsx | 18 +++++++++--------- .../__snapshots__/TabbedView-test.tsx.snap | 10 +++++----- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/test/unit-tests/components/structures/TabbedView-test.tsx b/test/unit-tests/components/structures/TabbedView-test.tsx index 9c30bc443ae..d199dfff264 100644 --- a/test/unit-tests/components/structures/TabbedView-test.tsx +++ b/test/unit-tests/components/structures/TabbedView-test.tsx @@ -16,15 +16,15 @@ import { _t } from "../../../../src/languageHandler"; describe("", () => { const generalTab = new Tab("GENERAL", "common|general", "general",
general
); const labsTab = new Tab("LABS", "common|labs", "labs",
labs
); - const securityTab = new Tab("SECURITY", "common|security", "security",
security
); + const appearanceTab = new Tab("APPEARANCE", "common|appearance", "appearance",
appearance
); const defaultProps = { tabLocation: TabLocation.LEFT, - tabs: [generalTab, labsTab, securityTab] as NonEmptyArray>, + tabs: [generalTab, labsTab, appearanceTab] as NonEmptyArray>, onChange: () => {}, }; const getComponent = ( props: { - activeTabId: "GENERAL" | "LABS" | "SECURITY"; + activeTabId: "GENERAL" | "LABS" | "APPEARANCE"; onChange?: () => any; tabs?: NonEmptyArray>; } = { @@ -44,9 +44,9 @@ describe("", () => { }); it("renders activeTabId tab as active when valid", () => { - const { container } = render(getComponent({ activeTabId: securityTab.id })); - expect(getActiveTab(container)?.textContent).toEqual(_t(securityTab.label)); - expect(getActiveTabBody(container)?.textContent).toEqual("security"); + const { container } = render(getComponent({ activeTabId: appearanceTab.id })); + expect(getActiveTab(container)?.textContent).toEqual(_t(appearanceTab.label)); + expect(getActiveTabBody(container)?.textContent).toEqual("appearance"); }); it("calls onchange on on tab click", () => { @@ -54,10 +54,10 @@ describe("", () => { const { getByTestId } = render(getComponent({ activeTabId: "GENERAL", onChange })); act(() => { - fireEvent.click(getByTestId(getTabTestId(securityTab))); + fireEvent.click(getByTestId(getTabTestId(appearanceTab))); }); - expect(onChange).toHaveBeenCalledWith(securityTab.id); + expect(onChange).toHaveBeenCalledWith(appearanceTab.id); }); it("keeps same tab active when order of tabs changes", () => { @@ -66,7 +66,7 @@ describe("", () => { expect(getActiveTab(container)?.textContent).toEqual(_t(labsTab.label)); - rerender(getComponent({ tabs: [labsTab, generalTab, securityTab], activeTabId: labsTab.id })); + rerender(getComponent({ tabs: [labsTab, generalTab, appearanceTab], activeTabId: labsTab.id })); // labs tab still active expect(getActiveTab(container)?.textContent).toEqual(_t(labsTab.label)); diff --git a/test/unit-tests/components/structures/__snapshots__/TabbedView-test.tsx.snap b/test/unit-tests/components/structures/__snapshots__/TabbedView-test.tsx.snap index fc2b259aaa6..540cfd233a5 100644 --- a/test/unit-tests/components/structures/__snapshots__/TabbedView-test.tsx.snap +++ b/test/unit-tests/components/structures/__snapshots__/TabbedView-test.tsx.snap @@ -47,21 +47,21 @@ exports[` renders tabs 1`] = ` From b5daa1a46d321c364ac16452ab1f97ac4c89b191 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Wed, 5 Feb 2025 20:12:59 +0530 Subject: [PATCH 07/11] Remove imports --- src/components/views/right_panel/UserInfo.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index ef3c398890b..8e8659b39f7 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -56,7 +56,6 @@ import { verifyDevice, verifyUser } from "../../../verification"; import { Action } from "../../../dispatcher/actions"; import { useIsEncrypted } from "../../../hooks/useIsEncrypted"; import BaseCard from "./BaseCard"; -import { E2EStatus } from "../../../utils/ShieldUtils"; import ImageView from "../elements/ImageView"; import Spinner from "../elements/Spinner"; import PowerSelector from "../elements/PowerSelector"; @@ -81,7 +80,6 @@ import PosthogTrackers from "../../../PosthogTrackers"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { DirectoryMember, startDmOnFirstMessage } from "../../../utils/direct-messages"; import { SdkContextClass } from "../../../contexts/SDKContext"; -import { asyncSome } from "../../../utils/arrays"; import { Flex } from "../../utils/Flex"; import CopyableText from "../elements/CopyableText"; import { useUserTimezone } from "../../../hooks/useUserTimezone"; From 85a9ae216697453cc55557682fbf8d1c7d693a86 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Wed, 5 Feb 2025 20:22:17 +0530 Subject: [PATCH 08/11] Remove console.log --- src/components/views/right_panel/UserInfo.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index 8e8659b39f7..786e29690bf 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -1244,7 +1244,6 @@ const VerificationSection: React.FC<{ !isMe && devices && devices.length > 0; - console.log("canVerify", canVerify, isMe); const hasCrossSigningKeys = useHasCrossSigningKeys(cli, member as User, canVerify); From 94ca4e28df3fdd9e1cdc2007c05390a637e9562b Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Wed, 5 Feb 2025 20:22:28 +0530 Subject: [PATCH 09/11] Update snapshots --- .../right_panel/__snapshots__/UserInfo-test.tsx.snap | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/test/unit-tests/components/views/right_panel/__snapshots__/UserInfo-test.tsx.snap b/test/unit-tests/components/views/right_panel/__snapshots__/UserInfo-test.tsx.snap index 8d75e8d6cec..60a7fb20a53 100644 --- a/test/unit-tests/components/views/right_panel/__snapshots__/UserInfo-test.tsx.snap +++ b/test/unit-tests/components/views/right_panel/__snapshots__/UserInfo-test.tsx.snap @@ -187,7 +187,9 @@ exports[` with crypto enabled renders 1`] = `

- (User verification unavailable) + ( + User verification unavailable + )

@@ -761,7 +763,9 @@ exports[` renders verification unavailable message 1`] = `

- (User verification unavailable) + ( + User verification unavailable + )

From a6967458be11bf956c10342363aa90cc218a18f3 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Wed, 5 Feb 2025 23:35:39 +0530 Subject: [PATCH 10/11] Fix broken tests --- playwright/e2e/crypto/dehydration.spec.ts | 25 ----------------- playwright/e2e/crypto/event-shields.spec.ts | 28 +++---------------- .../e2e/crypto/user-verification.spec.ts | 27 ++++++++---------- playwright/e2e/crypto/utils.ts | 24 ++++++++++++++++ 4 files changed, 39 insertions(+), 65 deletions(-) diff --git a/playwright/e2e/crypto/dehydration.spec.ts b/playwright/e2e/crypto/dehydration.spec.ts index b3ac82a19da..97082ea3d50 100644 --- a/playwright/e2e/crypto/dehydration.spec.ts +++ b/playwright/e2e/crypto/dehydration.spec.ts @@ -6,21 +6,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { Locator, type Page } from "@playwright/test"; - import { test, expect } from "../../element-web-test"; -import { viewRoomSummaryByName } from "../right-panel/utils"; import { isDendrite } from "../../plugins/homeserver/dendrite"; import { completeCreateSecretStorageDialog, createBot, logIntoElement } from "./utils.ts"; import { Client } from "../../pages/client.ts"; -const ROOM_NAME = "Test room"; const NAME = "Alice"; -function getMemberTileByName(page: Page, name: string): Locator { - return page.locator(`.mx_MemberTileView, [title="${name}"]`); -} - test.use({ displayName: NAME, synapseConfig: { @@ -70,23 +62,6 @@ test.describe("Dehydration", () => { // device. const sessionsTab = await app.settings.openUserSettings("Sessions"); await expect(sessionsTab.getByText("Dehydrated device")).not.toBeVisible(); - - await app.settings.closeDialog(); - - // now check that the user info right-panel shows the dehydrated device - // as a feature rather than as a normal device - await app.client.createRoom({ name: ROOM_NAME }); - - await viewRoomSummaryByName(page, app, ROOM_NAME); - - await page.locator(".mx_RightPanel").getByRole("menuitem", { name: "People" }).click(); - await expect(page.locator(".mx_MemberListView")).toBeVisible(); - - await getMemberTileByName(page, NAME).click(); - await page.locator(".mx_UserInfo_devices .mx_UserInfo_expand").click(); - - await expect(page.locator(".mx_UserInfo_devices").getByText("Offline device enabled")).toBeVisible(); - await expect(page.locator(".mx_UserInfo_devices").getByText("Dehydrated device")).not.toBeVisible(); }); test("Reset recovery key during login re-creates dehydrated device", async ({ diff --git a/playwright/e2e/crypto/event-shields.spec.ts b/playwright/e2e/crypto/event-shields.spec.ts index c0f1e280a2f..cd57175cf09 100644 --- a/playwright/e2e/crypto/event-shields.spec.ts +++ b/playwright/e2e/crypto/event-shields.spec.ts @@ -17,6 +17,7 @@ import { logIntoElement, logOutOfElement, verify, + waitForDevices, } from "./utils"; import { bootstrapCrossSigningForClient } from "../../pages/client.ts"; import { ElementAppPage } from "../../pages/ElementAppPage.ts"; @@ -144,25 +145,8 @@ test.describe("Cryptography", function () { // bob deletes his second device await bobSecondDevice.evaluate((cli) => cli.logout(true)); - // wait for the logout to propagate. Workaround for https://github.com/vector-im/element-web/issues/26263 by repeatedly closing and reopening Bob's user info. - async function awaitOneDevice(iterations = 1) { - const rightPanel = page.locator(".mx_RightPanel"); - await rightPanel.getByTestId("base-card-back-button").click(); - await rightPanel.getByText("Bob").click(); - const sessionCountText = await rightPanel - .locator(".mx_UserInfo_devices") - .getByText(" session", { exact: false }) - .textContent(); - // cf https://github.com/vector-im/element-web/issues/26279: Element-R uses the wrong text here - if (sessionCountText != "1 session" && sessionCountText != "1 verified session") { - if (iterations >= 10) { - throw new Error(`Bob still has ${sessionCountText} after 10 iterations`); - } - await awaitOneDevice(iterations + 1); - } - } - - await awaitOneDevice(); + // wait for the logout to propagate. + await waitForDevices(app, bob.credentials.userId, 1); // close and reopen the room, to get the shield to update. await app.viewRoomByName("Bob"); @@ -285,11 +269,7 @@ test.describe("Cryptography", function () { // Workaround for https://github.com/element-hq/element-web/issues/28640: // make sure that Alice has seen Bob's identity before she goes offline. We do this by opening // his user info. - await app.toggleRoomInfoPanel(); - const rightPanel = page.locator(".mx_RightPanel"); - await rightPanel.getByRole("menuitem", { name: "People" }).click(); - await rightPanel.getByRole("button", { name: bob.credentials!.userId }).click(); - await expect(rightPanel.locator(".mx_UserInfo_devices")).toContainText("1 session"); + await waitForDevices(app, bob.credentials.userId, 1); // Our app is blocked from syncing while Bob sends his messages. await app.client.network.goOffline(); diff --git a/playwright/e2e/crypto/user-verification.spec.ts b/playwright/e2e/crypto/user-verification.spec.ts index 175c8d5fdfd..35139fe3e9a 100644 --- a/playwright/e2e/crypto/user-verification.spec.ts +++ b/playwright/e2e/crypto/user-verification.spec.ts @@ -8,9 +8,8 @@ Please see LICENSE files in the repository root for full details. import { type Preset, type Visibility } from "matrix-js-sdk/src/matrix"; -import type { Page } from "@playwright/test"; import { test, expect } from "../../element-web-test"; -import { doTwoWaySasVerification, awaitVerifier } from "./utils"; +import { doTwoWaySasVerification, awaitVerifier, waitForDevices } from "./utils"; import { Client } from "../../pages/client"; test.describe("User verification", () => { @@ -33,13 +32,17 @@ test.describe("User verification", () => { }); test("can receive a verification request when there is no existing DM", async ({ + app, page, bot: bob, user: aliceCredentials, toasts, room: { roomId: dmRoomId }, }) => { - await waitForDeviceKeys(page); + await waitForDevices(app, bob.credentials.userId, 1); + await expect(page.getByRole("button", { name: "Avatar" })).toBeVisible(); + const avatar = page.getByRole("button", { name: "Avatar" }); + await avatar.click(); // once Alice has joined, Bob starts the verification const bobVerificationRequest = await bob.evaluateHandle( @@ -84,13 +87,17 @@ test.describe("User verification", () => { }); test("can abort emoji verification when emoji mismatch", async ({ + app, page, bot: bob, user: aliceCredentials, toasts, room: { roomId: dmRoomId }, }) => { - await waitForDeviceKeys(page); + await waitForDevices(app, bob.credentials.userId, 1); + await expect(page.getByRole("button", { name: "Avatar" })).toBeVisible(); + const avatar = page.getByRole("button", { name: "Avatar" }); + await avatar.click(); // once Alice has joined, Bob starts the verification const bobVerificationRequest = await bob.evaluateHandle( @@ -154,15 +161,3 @@ async function createDMRoom(client: Client, userId: string): Promise { ], }); } - -/** - * Wait until we get the other user's device keys. - * In newer rust-crypto versions, the verification request will be ignored if we - * don't have the sender's device keys. - */ -async function waitForDeviceKeys(page: Page): Promise { - await expect(page.getByRole("button", { name: "Avatar" })).toBeVisible(); - const avatar = await page.getByRole("button", { name: "Avatar" }); - await avatar.click(); - await expect(page.getByText("1 session")).toBeVisible(); -} diff --git a/playwright/e2e/crypto/utils.ts b/playwright/e2e/crypto/utils.ts index 6753ae651c3..656c9607ca0 100644 --- a/playwright/e2e/crypto/utils.ts +++ b/playwright/e2e/crypto/utils.ts @@ -494,3 +494,27 @@ export async function deleteCachedSecrets(page: Page) { }); await page.reload(); } + +/** + * Wait until the given user has a given number of devices. + * This function will check the device keys ten times and if + * the expected number of devices were not found by then, an + * error is thrown. + */ +export async function waitForDevices(app: ElementAppPage, userId: string, expectedNumberOfDevices: number): Promise { + const result = await app.client.evaluate( + async (cli, { userId, expectedNumberOfDevices }) => { + for (let i = 0; i < 10; ++i) { + const userDeviceMap = await cli.getCrypto()?.getUserDeviceInfo([userId], true); + const deviceMap = userDeviceMap?.get(userId); + if (deviceMap.size === expectedNumberOfDevices) return true; + await new Promise((r) => setTimeout(r, 500)); + } + return false; + }, + { userId, expectedNumberOfDevices }, + ); + if (!result) { + throw new Error(`User ${userId} did not have ${expectedNumberOfDevices} devices within ten iterations!`); + } +} From f3835d6f19a93b8e8b1d4718d02db881195ed34b Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Wed, 5 Feb 2025 23:49:53 +0530 Subject: [PATCH 11/11] Fix lint --- playwright/e2e/crypto/utils.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/playwright/e2e/crypto/utils.ts b/playwright/e2e/crypto/utils.ts index 884fec06c06..da2d00e9060 100644 --- a/playwright/e2e/crypto/utils.ts +++ b/playwright/e2e/crypto/utils.ts @@ -506,7 +506,11 @@ export async function deleteCachedSecrets(page: Page) { * the expected number of devices were not found by then, an * error is thrown. */ -export async function waitForDevices(app: ElementAppPage, userId: string, expectedNumberOfDevices: number): Promise { +export async function waitForDevices( + app: ElementAppPage, + userId: string, + expectedNumberOfDevices: number, +): Promise { const result = await app.client.evaluate( async (cli, { userId, expectedNumberOfDevices }) => { for (let i = 0; i < 10; ++i) {